diff options
author | Micaël Bergeron <mbergeron@gitlab.com> | 2018-03-22 16:06:10 +0300 |
---|---|---|
committer | Micaël Bergeron <mbergeron@gitlab.com> | 2018-03-22 16:06:10 +0300 |
commit | 9c6663ea079128bb730ec2a168b43961cd9462ec (patch) | |
tree | 4f62c2e745c7f3e8571ee8c023abcce316c68275 /spec | |
parent | 6801a93e5e7447199b091e44f33c96d22a1a1960 (diff) | |
parent | c01697539c3da4e72b1812662ff35d1f709d1dcc (diff) |
Merge remote-tracking branch 'origin/master' into 40781-os-to-ce
Diffstat (limited to 'spec')
248 files changed, 8150 insertions, 715 deletions
diff --git a/spec/controllers/admin/projects_controller_spec.rb b/spec/controllers/admin/projects_controller_spec.rb index d5a3c250f31..cc200b9fed9 100644 --- a/spec/controllers/admin/projects_controller_spec.rb +++ b/spec/controllers/admin/projects_controller_spec.rb @@ -31,5 +31,15 @@ describe Admin::ProjectsController do expect(response.body).not_to match(pending_delete_project.name) expect(response.body).to match(project.name) end + + it 'does not have N+1 queries', :use_clean_rails_memory_store_caching, :request_store do + get :index + + control_count = ActiveRecord::QueryRecorder.new { get :index }.count + + create(:project) + + expect { get :index }.not_to exceed_query_limit(control_count) + end end end diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb index 7ada3b904d3..3d3287d8168 100644 --- a/spec/factories/ci/job_artifacts.rb +++ b/spec/factories/ci/job_artifacts.rb @@ -39,5 +39,11 @@ FactoryBot.define do Rails.root.join('spec/fixtures/trace/sample_trace'), 'text/plain') end end + + trait :correct_checksum do + after(:build) do |artifact, evaluator| + artifact.file_sha256 = Digest::SHA256.file(artifact.file.path).hexdigest + end + end end end diff --git a/spec/factories/internal_ids.rb b/spec/factories/internal_ids.rb new file mode 100644 index 00000000000..fbde07a391a --- /dev/null +++ b/spec/factories/internal_ids.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :internal_id do + project + usage :issues + last_value { project.issues.maximum(:iid) || 0 } + end +end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 3d13f806b11..52ff47d57f9 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -343,7 +343,7 @@ describe 'Issue Boards', :js do wait_for_requests - click_link 'Create new label' + click_link 'Create project label' fill_in('new_label_name', with: 'Testing New Label') diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index b2dbfcd0031..d4c44c1adf9 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -312,12 +312,12 @@ describe 'Issue Boards', :js do expect(card).not_to have_content(stretch.title) end - it 'creates new label' do + it 'creates project label' do click_card(card) page.within('.labels') do click_link 'Edit' - click_link 'Create new label' + click_link 'Create project label' fill_in 'new_label_name', with: 'test label' first('.suggest-colors-dropdown a').click click_button 'Create' diff --git a/spec/features/boards/sub_group_project_spec.rb b/spec/features/boards/sub_group_project_spec.rb index 11a54079f4f..5fdb8044db2 100644 --- a/spec/features/boards/sub_group_project_spec.rb +++ b/spec/features/boards/sub_group_project_spec.rb @@ -24,7 +24,7 @@ describe 'Sub-group project issue boards', :js do page.within '.labels' do click_link 'Edit' - click_link 'Create new label' + click_link 'Create project label' end page.within '.dropdown-new-label' do diff --git a/spec/features/ci_lint_spec.rb b/spec/features/ci_lint_spec.rb index b1dceec9da8..220b934154e 100644 --- a/spec/features/ci_lint_spec.rb +++ b/spec/features/ci_lint_spec.rb @@ -39,6 +39,7 @@ describe 'CI Lint', :js do it 'displays information about an error' do expect(page).to have_content('Status: syntax is incorrect') + expect(page).to have_selector('.ace_content', text: yaml_content) end end diff --git a/spec/features/issues/form_spec.rb b/spec/features/issues/form_spec.rb index ef6b8edd0ad..38c618d300e 100644 --- a/spec/features/issues/form_spec.rb +++ b/spec/features/issues/form_spec.rb @@ -306,10 +306,10 @@ describe 'New/edit issue', :js do visit new_project_issue_path(sub_group_project) end - it 'creates new label from dropdown' do + it 'creates project label from dropdown' do click_button 'Labels' - click_link 'Create new label' + click_link 'Create project label' page.within '.dropdown-new-label' do fill_in 'new_label_name', with: 'test label' diff --git a/spec/features/issues/issue_sidebar_spec.rb b/spec/features/issues/issue_sidebar_spec.rb index 64b4f9e7e67..b835558b142 100644 --- a/spec/features/issues/issue_sidebar_spec.rb +++ b/spec/features/issues/issue_sidebar_spec.rb @@ -117,22 +117,22 @@ feature 'Issue Sidebar' do end end - it 'shows option to create a new label' do + it 'shows option to create a project label' do page.within('.block.labels') do - expect(page).to have_content 'Create new' + expect(page).to have_content 'Create project' end end - context 'creating a new label', :js do + context 'creating a project label', :js do before do page.within('.block.labels') do - click_link 'Create new' + click_link 'Create project' end end it 'shows dropdown switches to "create label" section' do page.within('.block.labels') do - expect(page).to have_content 'Create new label' + expect(page).to have_content 'Create project label' end end diff --git a/spec/features/markdown/copy_as_gfm_spec.rb b/spec/features/markdown/copy_as_gfm_spec.rb index f82ed6300cc..4d897f09b57 100644 --- a/spec/features/markdown/copy_as_gfm_spec.rb +++ b/spec/features/markdown/copy_as_gfm_spec.rb @@ -20,7 +20,7 @@ describe 'Copy as GFM', :js do end # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/copy_as_gfm.js consequently convert that same HTML to GFM. + # The handlers defined in app/assets/javascripts/behaviors/markdown/copy_as_gfm.js consequently convert that same HTML to GFM. # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb index 890774922aa..db92a3504f3 100644 --- a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb +++ b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb @@ -125,6 +125,12 @@ describe 'Merge request > User merges when pipeline succeeds', :js do expect(page).to have_content "canceled the automatic merge" end + it 'allows to remove source branch' do + click_link "Remove source branch" + + expect(page).to have_content "The source branch will be removed" + end + context 'when pipeline succeeds' do before do build.success diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb index 3e83a549682..b4ad4b64d8e 100644 --- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb +++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb @@ -108,6 +108,7 @@ describe 'Merge request > User resolves diff notes and discussions', :js do it 'shows resolved discussion when toggled' do find(".timeline-content .discussion[data-discussion-id='#{note.discussion_id}'] .discussion-toggle-button").click + expect(page.find(".line-holder-placeholder")).to be_visible expect(page.find(".timeline-content #note_#{note.id}")).to be_visible end end diff --git a/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb b/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb index 8a834adbf17..565e375600b 100644 --- a/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb +++ b/spec/features/merge_request/user_scrolls_to_note_on_load_spec.rb @@ -5,15 +5,18 @@ describe 'Merge request > User scrolls to note on load', :js do let(:user) { project.creator } let(:merge_request) { create(:merge_request, source_project: project, author: user) } let(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } + let(:resolved_note) { create(:diff_note_on_merge_request, :resolved, noteable: merge_request, project: project) } let(:fragment_id) { "#note_#{note.id}" } + let(:collapsed_fragment_id) { "#note_#{resolved_note.id}" } before do sign_in(user) page.current_window.resize_to(1000, 300) - visit "#{project_merge_request_path(project, merge_request)}#{fragment_id}" end - it 'scrolls down to fragment' do + it 'scrolls note into view' do + visit "#{project_merge_request_path(project, merge_request)}#{fragment_id}" + page_height = page.current_window.size[1] page_scroll_y = page.evaluate_script("window.scrollY") fragment_position_top = page.evaluate_script("Math.round($('#{fragment_id}').offset().top)") @@ -23,4 +26,13 @@ describe 'Merge request > User scrolls to note on load', :js do expect(fragment_position_top).to be >= page_scroll_y expect(fragment_position_top).to be < (page_scroll_y + page_height) end + + it 'expands collapsed notes' do + visit "#{project_merge_request_path(project, merge_request)}#{collapsed_fragment_id}" + note_element = find(collapsed_fragment_id) + note_container = note_element.ancestor('.js-toggle-container') + + expect(note_element.visible?).to eq true + expect(note_container.find('.line_content.noteable_line.old', match: :first).visible?).to eq true + end end diff --git a/spec/features/merge_request/user_sees_deployment_widget_spec.rb b/spec/features/merge_request/user_sees_deployment_widget_spec.rb index 3abe363d523..f744d7941f5 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -22,7 +22,7 @@ describe 'Merge request > User sees deployment widget', :js do wait_for_requests expect(page).to have_content("Deployed to #{environment.name}") - expect(find('.js-deploy-time')['data-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) + expect(find('.js-deploy-time')['data-original-title']).to eq(deployment.created_at.to_time.in_time_zone.to_s(:medium)) end context 'with stop action' do diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb index 56224e505d9..51a65407aec 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -1,6 +1,8 @@ require 'rails_helper' describe 'Merge request > User sees merge widget', :js do + include ProjectForksHelper + let(:project) { create(:project, :repository) } let(:project_only_mwps) { create(:project, :repository, only_allow_merge_if_pipeline_succeeds: true) } let(:user) { project.creator } @@ -285,7 +287,29 @@ describe 'Merge request > User sees merge widget', :js do end it 'user cannot remove source branch' do - expect(page).to have_field('remove-source-branch-input', disabled: true) + expect(page).not_to have_field('remove-source-branch-input') + end + end + + context 'user cannot merge project and cannot push to fork', :js do + let(:forked_project) { fork_project(project, nil, repository: true) } + let(:user2) { create(:user) } + + before do + project.add_developer(user2) + sign_out(:user) + sign_in(user2) + merge_request.update( + source_project: forked_project, + target_project: project, + merge_params: { 'force_remove_source_branch' => '1' } + ) + visit project_merge_request_path(project, merge_request) + end + + it 'user cannot remove source branch' do + expect(page).not_to have_field('remove-source-branch-input') + expect(page).to have_content('Removes source branch') end end diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb index cc12a1005ba..19152bf1f0f 100644 --- a/spec/features/milestone_spec.rb +++ b/spec/features/milestone_spec.rb @@ -97,4 +97,15 @@ feature 'Milestone' do end end end + + feature 'Deleting a milestone' do + scenario "The delete milestone button does not show for unauthorized users" do + create(:milestone, project: project, title: 8.7) + sign_out(user) + + visit group_milestones_path(group) + + expect(page).to have_selector('.js-delete-milestone-button', count: 0) + end + end end diff --git a/spec/features/profiles/user_visits_notifications_tab_spec.rb b/spec/features/profiles/user_visits_notifications_tab_spec.rb index 1952fdae798..95953fbcfac 100644 --- a/spec/features/profiles/user_visits_notifications_tab_spec.rb +++ b/spec/features/profiles/user_visits_notifications_tab_spec.rb @@ -16,6 +16,6 @@ feature 'User visits the notifications tab', :js do first('#notifications-button').click click_link('On mention') - expect(page).to have_content('On mention') + expect(page).to have_selector('#notifications-button', text: 'On mention') end end diff --git a/spec/features/projects/actve_tabs_spec.rb b/spec/features/projects/actve_tabs_spec.rb new file mode 100644 index 00000000000..0bda68b83e7 --- /dev/null +++ b/spec/features/projects/actve_tabs_spec.rb @@ -0,0 +1,137 @@ +require 'spec_helper' + +describe 'Project active tab' do + let(:user) { create :user } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + end + + def click_tab(title) + page.within '.sidebar-top-level-items > .active' do + click_link(title) + end + end + + shared_examples 'page has active tab' do |title| + it "activates #{title} tab" do + expect(page).to have_selector('.sidebar-top-level-items > li.active', count: 1) + expect(find('.sidebar-top-level-items > li.active')).to have_content(title) + end + end + + shared_examples 'page has active sub tab' do |title| + it "activates #{title} sub tab" do + expect(page).to have_selector('.sidebar-sub-level-items > li.active:not(.fly-out-top-item)', count: 1) + expect(find('.sidebar-sub-level-items > li.active:not(.fly-out-top-item)')) + .to have_content(title) + end + end + + context 'on project Home' do + before do + visit project_path(project) + end + + it_behaves_like 'page has active tab', 'Overview' + it_behaves_like 'page has active sub tab', 'Details' + + context 'on project Home/Activity' do + before do + click_tab('Activity') + end + + it_behaves_like 'page has active tab', 'Overview' + it_behaves_like 'page has active sub tab', 'Activity' + end + end + + context 'on project Repository' do + before do + root_ref = project.repository.root_ref + visit project_tree_path(project, root_ref) + end + + it_behaves_like 'page has active tab', 'Repository' + + %w(Files Commits Graph Compare Charts Branches Tags).each do |sub_menu| + context "on project Repository/#{sub_menu}" do + before do + click_tab(sub_menu) + end + + it_behaves_like 'page has active tab', 'Repository' + it_behaves_like 'page has active sub tab', sub_menu + end + end + end + + context 'on project Issues' do + before do + visit project_issues_path(project) + end + + it_behaves_like 'page has active tab', 'Issues' + + %w(Milestones Labels).each do |sub_menu| + context "on project Issues/#{sub_menu}" do + before do + click_tab(sub_menu) + end + + it_behaves_like 'page has active tab', 'Issues' + it_behaves_like 'page has active sub tab', sub_menu + end + end + end + + context 'on project Merge Requests' do + before do + visit project_merge_requests_path(project) + end + + it_behaves_like 'page has active tab', 'Merge Requests' + end + + context 'on project Wiki' do + before do + visit project_wiki_path(project, :home) + end + + it_behaves_like 'page has active tab', 'Wiki' + end + + context 'on project Members' do + before do + visit project_project_members_path(project) + end + + it_behaves_like 'page has active tab', 'Members' + end + + context 'on project Settings' do + before do + visit edit_project_path(project) + end + + context 'on project Settings/Integrations' do + before do + click_tab('Integrations') + end + + it_behaves_like 'page has active tab', 'Settings' + it_behaves_like 'page has active sub tab', 'Integrations' + end + + context 'on project Settings/Repository' do + before do + click_tab('Repository') + end + + it_behaves_like 'page has active tab', 'Settings' + it_behaves_like 'page has active sub tab', 'Repository' + end + end +end diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 88813d9b5ff..ac82f869f0f 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -509,4 +509,29 @@ feature 'File blob', :js do end end end + + context 'realtime pipelines' do + before do + Files::CreateService.new( + project, + project.creator, + start_branch: 'feature', + branch_name: 'feature', + commit_message: "Add ruby file", + file_path: 'files/ruby/test.rb', + file_content: "# Awesome content" + ).execute + + create(:ci_pipeline, status: 'running', project: project, ref: 'feature', sha: project.commit('feature').sha) + visit_blob('files/ruby/test.rb', ref: 'feature') + end + + it 'should show the realtime pipeline status' do + page.within('.commit-actions') do + expect(page).to have_css('.ci-status-icon') + expect(page).to have_css('.ci-status-icon-running') + expect(page).to have_css('.js-ci-status-icon-running') + end + end + end end diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index 842840cc04c..1743b1e083f 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -25,6 +25,110 @@ describe 'Project fork' do expect(page).to have_css('a.disabled', text: 'Fork') end + it 'forks the project' do + visit project_path(project) + + click_link 'Fork' + + page.within '.fork-thumbnail-container' do + click_link user.name + end + + expect(page).to have_content 'Forked from' + + visit project_path(project) + + expect(page).to have_content(/new merge request/i) + + page.within '.nav-sidebar' do + first(:link, 'Merge Requests').click + end + + expect(page).to have_content(/new merge request/i) + + page.within '#content-body' do + click_link('New merge request') + end + + expect(current_path).to have_content(/#{user.namespace.name}/i) + end + + it 'shows the forked project on the list' do + visit project_path(project) + + click_link 'Fork' + + page.within '.fork-thumbnail-container' do + click_link user.name + end + + visit project_forks_path(project) + + forked_project = user.fork_of(project.reload) + + page.within('.js-projects-list-holder') do + expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}") + end + + forked_project.update!(path: 'test-crappy-path') + + visit project_forks_path(project) + + page.within('.js-projects-list-holder') do + expect(page).to have_content("#{forked_project.namespace.human_name} / #{forked_project.name}") + end + end + + context 'when the project is private' do + let(:project) { create(:project, :repository) } + let(:another_user) { create(:user, name: 'Mike') } + + before do + project.add_reporter(user) + project.add_reporter(another_user) + end + + it 'renders private forks of the project' do + visit project_path(project) + + another_project_fork = Projects::ForkService.new(project, another_user).execute + + click_link 'Fork' + + page.within '.fork-thumbnail-container' do + click_link user.name + end + + visit project_forks_path(project) + + page.within('.js-projects-list-holder') do + user_project_fork = user.fork_of(project.reload) + expect(page).to have_content("#{user_project_fork.namespace.human_name} / #{user_project_fork.name}") + end + + expect(page).not_to have_content("#{another_project_fork.namespace.human_name} / #{another_project_fork.name}") + expect(page).to have_content("1 private fork") + end + end + + context 'when the user already forked the project' do + before do + create(:project, :repository, name: project.name, namespace: user.namespace) + end + + it 'renders error' do + visit project_path(project) + + click_link 'Fork' + + page.within '.fork-thumbnail-container' do + click_link user.name + end + + expect(page).to have_content "Name has already been taken" + end + end + context 'master in group' do let(:group) { create(:group) } diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb new file mode 100644 index 00000000000..57172610aed --- /dev/null +++ b/spec/features/projects/graph_spec.rb @@ -0,0 +1,75 @@ +require 'spec_helper' + +describe 'Project Graph', :js do + let(:user) { create :user } + let(:project) { create(:project, :repository, namespace: user.namespace) } + + before do + project.add_master(user) + + sign_in(user) + end + + shared_examples 'page should have commits graphs' do + it 'renders commits' do + expect(page).to have_content('Commit statistics for master') + expect(page).to have_content('Commits per day of month') + end + end + + shared_examples 'page should have languages graphs' do + it 'renders languages' do + expect(page).to have_content(/Ruby 66.* %/) + expect(page).to have_content(/JavaScript 22.* %/) + end + end + + it 'renders graphs' do + visit project_graph_path(project, 'master') + + expect(page).to have_selector('.stat-graph', visible: false) + end + + context 'commits graph' do + before do + visit commits_project_graph_path(project, 'master') + end + + it_behaves_like 'page should have commits graphs' + it_behaves_like 'page should have languages graphs' + end + + context 'languages graph' do + before do + visit languages_project_graph_path(project, 'master') + end + + it_behaves_like 'page should have commits graphs' + it_behaves_like 'page should have languages graphs' + end + + context 'charts graph' do + before do + visit charts_project_graph_path(project, 'master') + end + + it_behaves_like 'page should have commits graphs' + it_behaves_like 'page should have languages graphs' + end + + context 'when CI enabled' do + before do + project.enable_ci + + visit ci_project_graph_path(project, 'master') + end + + it 'renders CI graphs' do + expect(page).to have_content 'Overall' + expect(page).to have_content 'Pipelines for last week' + expect(page).to have_content 'Pipelines for last month' + expect(page).to have_content 'Pipelines for last year' + expect(page).to have_content 'Commit duration in minutes for last 30 commits' + 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 e8bb9c6a86c..b25f5161748 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -41,6 +41,7 @@ feature 'Import/Export - project import integration test', :js do project = Project.last expect(project).not_to be_nil + expect(project.description).to eq("Foo Bar") expect(project.issues).not_to be_empty expect(project.merge_requests).not_to be_empty expect(project_hook_exists?(project)).to be true diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 33ad59abfdf..0e81c6c629a 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -349,6 +349,18 @@ describe 'Pipelines', :js do it { expect(page).not_to have_selector('.build-artifacts') } end + + context 'with trace artifact' do + before do + create(:ci_build, :success, :trace_artifact, pipeline: pipeline) + + visit_project_pipelines + end + + it 'does not show trace artifact as artifacts' do + expect(page).not_to have_selector('.build-artifacts') + end + end end context 'mini pipeline graph' do diff --git a/spec/features/projects/redirects_spec.rb b/spec/features/projects/redirects_spec.rb new file mode 100644 index 00000000000..d1d8ca07035 --- /dev/null +++ b/spec/features/projects/redirects_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +describe 'Project redirects' do + let(:user) { create :user } + let(:public_project) { create :project, :public } + let(:private_project) { create :project, :private } + + before do + allow(Gitlab.config.gitlab).to receive(:host).and_return('www.example.com') + end + + it 'shows public project page' do + visit project_path(public_project) + + page.within '.breadcrumbs .breadcrumb-item-text' do + expect(page).to have_content(public_project.name) + end + end + + it 'redirects to sign in page when project is private' do + visit project_path(private_project) + + expect(current_path).to eq(new_user_session_path) + end + + it 'redirects to sign in page when project does not exist' do + visit project_path(build(:project, :public)) + + expect(current_path).to eq(new_user_session_path) + end + + it 'redirects to public project page after signing in' do + visit project_path(public_project) + + first(:link, 'Sign in').click + + fill_in 'user_login', with: user.email + fill_in 'user_password', with: user.password + click_button 'Sign in' + + expect(status_code).to eq(200) + expect(current_path).to eq("/#{public_project.full_path}") + end + + it 'redirects to private project page after sign in' do + visit project_path(private_project) + + owner = private_project.owner + fill_in 'user_login', with: owner.email + fill_in 'user_password', with: owner.password + click_button 'Sign in' + + expect(status_code).to eq(200) + expect(current_path).to eq("/#{private_project.full_path}") + end + + context 'when signed in' do + before do + sign_in(user) + end + + it 'returns 404 status when project does not exist' do + visit project_path(build(:project, :public)) + + expect(status_code).to eq(404) + end + + it 'returns 404 when project is private' do + visit project_path(private_project) + + expect(status_code).to eq(404) + end + end +end diff --git a/spec/features/projects/show_project_spec.rb b/spec/features/projects/show_project_spec.rb index 0a014e9f080..e4f13e6cab7 100644 --- a/spec/features/projects/show_project_spec.rb +++ b/spec/features/projects/show_project_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe 'Project show page', :feature do + include DropzoneHelper + context 'when project pending delete' do let(:project) { create(:project, :empty_repo, pending_delete: true) } @@ -334,4 +336,24 @@ describe 'Project show page', :feature do end end end + + describe 'dropzone', :js do + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + + visit project_path(project) + end + + it 'can upload files' do + find('.add-to-tree').click + click_link 'Upload file' + drop_in_dropzone(File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt')) + + expect(find('.dz-filename')).to have_content('doc_sample.txt') + end + end end diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb new file mode 100644 index 00000000000..d96c7e655ba --- /dev/null +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +feature 'Multi-file editor new directory', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + visit project_tree_path(project, :master) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'creates directory in current directory' do + find('.add-to-tree').click + + click_link('New directory') + + page.within('.modal') do + find('.form-control').set('folder name') + + click_button('Create directory') + end + + find('.add-to-tree').click + + click_link('New file') + + page.within('.modal-dialog') do + find('.form-control').set('file name') + + click_button('Create file') + end + + wait_for_requests + + fill_in('commit-message', with: 'commit message ide') + + click_button('Commit') + + 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 new file mode 100644 index 00000000000..a4cbd5cf766 --- /dev/null +++ b/spec/features/projects/tree/create_file_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +feature 'Multi-file editor new file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + visit project_path(project) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'creates file in current directory' do + find('.add-to-tree').click + + click_link('New file') + + page.within('.modal') do + find('.form-control').set('file name') + + click_button('Create file') + end + + wait_for_requests + + fill_in('commit-message', with: 'commit message ide') + + click_button('Commit') + + expect(page).to have_content('file name') + end +end diff --git a/spec/features/projects/tree/tree_show_spec.rb b/spec/features/projects/tree/tree_show_spec.rb index c8a17871508..c4b3fb9d171 100644 --- a/spec/features/projects/tree/tree_show_spec.rb +++ b/spec/features/projects/tree/tree_show_spec.rb @@ -25,4 +25,18 @@ feature 'Projects tree' do expect(page).to have_selector('.label-lfs', text: 'LFS') end end + + context 'web IDE', :js do + before do + visit project_tree_path(project, File.join('master', 'bar')) + + click_link 'Web IDE' + + find('.ide-file-list') + end + + it 'opens folder in IDE' do + expect(page).to have_selector('.is-open', text: 'bar') + end + end end diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb new file mode 100644 index 00000000000..8e53ae15700 --- /dev/null +++ b/spec/features/projects/tree/upload_file_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +feature 'Multi-file editor upload file', :js do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + let(:txt_file) { File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt') } + let(:img_file) { File.join(Rails.root, 'spec', 'fixtures', 'dk.png') } + + before do + project.add_master(user) + sign_in(user) + + visit project_tree_path(project, :master) + + wait_for_requests + + click_link('Web IDE') + + wait_for_requests + end + + after do + set_cookie('new_repo', 'false') + end + + it 'uploads text file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', txt_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.multi-file-tab', text: 'doc_sample.txt') + expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline)) + end + + it 'uploads image file' do + find('.add-to-tree').click + + # make the field visible so capybara can use it + execute_script('document.querySelector("#file-upload").classList.remove("hidden")') + attach_file('file-upload', img_file) + + find('.add-to-tree').click + + expect(page).to have_selector('.multi-file-tab', text: 'dk.png') + expect(page).not_to have_selector('.monaco-editor') + end +end diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb index 975c157bcf5..e069c2fddd1 100644 --- a/spec/features/user_can_display_performance_bar_spec.rb +++ b/spec/features/user_can_display_performance_bar_spec.rb @@ -3,7 +3,7 @@ require 'rails_helper' describe 'User can display performance bar', :js do shared_examples 'performance bar cannot be displayed' do it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end context 'when user press `pb`' do @@ -12,14 +12,14 @@ describe 'User can display performance bar', :js do end it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end end end shared_examples 'performance bar can be displayed' do it 'does not show the performance bar by default' do - expect(page).not_to have_css('#peek') + expect(page).not_to have_css('#js-peek') end context 'when user press `pb`' do @@ -28,7 +28,7 @@ describe 'User can display performance bar', :js do end it 'shows the performance bar' do - expect(page).to have_css('#peek') + expect(page).to have_css('#js-peek') end end end @@ -41,7 +41,7 @@ describe 'User can display performance bar', :js do it 'shows the performance bar by default' do refresh # Because we're stubbing Rails.env after the 1st visit to root_path - expect(page).to have_css('#peek') + expect(page).to have_css('#js-peek') end end diff --git a/spec/helpers/import_helper_spec.rb b/spec/helpers/import_helper_spec.rb index 9afff47f4e9..033155617c6 100644 --- a/spec/helpers/import_helper_spec.rb +++ b/spec/helpers/import_helper_spec.rb @@ -27,25 +27,46 @@ describe ImportHelper do describe '#provider_project_link' do context 'when provider is "github"' do + let(:github_server_url) { nil } + let(:provider) { OpenStruct.new(name: 'github', url: github_server_url) } + + before do + stub_omniauth_setting(providers: [provider]) + end + context 'when provider does not specify a custom URL' do it 'uses default GitHub URL' do - allow(Gitlab.config.omniauth).to receive(:providers) - .and_return([Settingslogic.new('name' => 'github')]) - expect(helper.provider_project_link('github', 'octocat/Hello-World')) .to include('href="https://github.com/octocat/Hello-World"') end end context 'when provider specify a custom URL' do + let(:github_server_url) { 'https://github.company.com' } + it 'uses custom URL' do - allow(Gitlab.config.omniauth).to receive(:providers) - .and_return([Settingslogic.new('name' => 'github', 'url' => 'https://github.company.com')]) + expect(helper.provider_project_link('github', 'octocat/Hello-World')) + .to include('href="https://github.company.com/octocat/Hello-World"') + end + end + + context "when custom URL contains a '/' char at the end" do + let(:github_server_url) { 'https://github.company.com/' } + it "doesn't render double slash" do expect(helper.provider_project_link('github', 'octocat/Hello-World')) .to include('href="https://github.company.com/octocat/Hello-World"') end end + + context 'when provider is missing' do + it 'uses the default URL' do + allow(Gitlab.config.omniauth).to receive(:providers).and_return([]) + + expect(helper.provider_project_link('github', 'octocat/Hello-World')) + .to include('href="https://github.com/octocat/Hello-World"') + end + end end context 'when provider is "gitea"' do diff --git a/spec/helpers/labels_helper_spec.rb b/spec/helpers/labels_helper_spec.rb index 619baa78bfa..a2cda58e5d2 100644 --- a/spec/helpers/labels_helper_spec.rb +++ b/spec/helpers/labels_helper_spec.rb @@ -139,4 +139,76 @@ describe LabelsHelper do expect(text_color_for_bg('#000')).to eq '#FFFFFF' end end + + describe 'create_label_title' do + set(:group) { create(:group) } + + context 'with a group as subject' do + it 'returns "Create group label"' do + expect(create_label_title(group)).to eq 'Create group label' + end + end + + context 'with a project as subject' do + set(:project) { create(:project, namespace: group) } + + it 'returns "Create project label"' do + expect(create_label_title(project)).to eq 'Create project label' + end + end + + context 'with no subject' do + it 'returns "Create new label"' do + expect(create_label_title(nil)).to eq 'Create new label' + end + end + end + + describe 'manage_labels_title' do + set(:group) { create(:group) } + + context 'with a group as subject' do + it 'returns "Manage group labels"' do + expect(manage_labels_title(group)).to eq 'Manage group labels' + end + end + + context 'with a project as subject' do + set(:project) { create(:project, namespace: group) } + + it 'returns "Manage project labels"' do + expect(manage_labels_title(project)).to eq 'Manage project labels' + end + end + + context 'with no subject' do + it 'returns "Manage labels"' do + expect(manage_labels_title(nil)).to eq 'Manage labels' + end + end + end + + describe 'view_labels_title' do + set(:group) { create(:group) } + + context 'with a group as subject' do + it 'returns "View group labels"' do + expect(view_labels_title(group)).to eq 'View group labels' + end + end + + context 'with a project as subject' do + set(:project) { create(:project, namespace: group) } + + it 'returns "View project labels"' do + expect(view_labels_title(project)).to eq 'View project labels' + end + end + + context 'with no subject' do + it 'returns "View labels"' do + expect(view_labels_title(nil)).to eq 'View labels' + end + end + end end diff --git a/spec/javascripts/activities_spec.js b/spec/javascripts/activities_spec.js index 7a9c539e9d0..909a1bf76bc 100644 --- a/spec/javascripts/activities_spec.js +++ b/spec/javascripts/activities_spec.js @@ -1,5 +1,6 @@ /* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow, max-len */ +import $ from 'jquery'; import 'vendor/jquery.endless-scroll'; import Activities from '~/activities'; diff --git a/spec/javascripts/ajax_loading_spinner_spec.js b/spec/javascripts/ajax_loading_spinner_spec.js index 95c2c122403..261375d3a0e 100644 --- a/spec/javascripts/ajax_loading_spinner_spec.js +++ b/spec/javascripts/ajax_loading_spinner_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import AjaxLoadingSpinner from '~/ajax_loading_spinner'; describe('Ajax Loading Spinner', () => { @@ -10,7 +11,7 @@ describe('Ajax Loading Spinner', () => { }); it('change current icon with spinner icon and disable link while waiting ajax response', (done) => { - spyOn(jQuery, 'ajax').and.callFake((req) => { + spyOn($, 'ajax').and.callFake((req) => { const xhr = new XMLHttpRequest(); const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner'); const icon = ajaxLoadingSpinner.querySelector('i'); @@ -33,7 +34,7 @@ describe('Ajax Loading Spinner', () => { }); it('use original icon again and enabled the link after complete the ajax request', (done) => { - spyOn(jQuery, 'ajax').and.callFake((req) => { + spyOn($, 'ajax').and.callFake((req) => { const xhr = new XMLHttpRequest(); const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner'); diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js index b568d7fa8b0..38ae5b7e00c 100644 --- a/spec/javascripts/autosave_spec.js +++ b/spec/javascripts/autosave_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Autosave from '~/autosave'; import AccessorUtilities from '~/lib/utils/accessor'; diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index 8e4bbb90ccb..e81055bc08f 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, comma-dangle, new-parens, no-unused-vars, quotes, jasmine/no-spec-dupes, prefer-template, max-len */ +import $ from 'jquery'; import Cookies from 'js-cookie'; import loadAwardsHandler from '~/awards_handler'; diff --git a/spec/javascripts/behaviors/autosize_spec.js b/spec/javascripts/behaviors/autosize_spec.js index 960b731892a..c411c5174fb 100644 --- a/spec/javascripts/behaviors/autosize_spec.js +++ b/spec/javascripts/behaviors/autosize_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import '~/behaviors/autosize'; function load() { diff --git a/spec/javascripts/behaviors/copy_as_gfm_spec.js b/spec/javascripts/behaviors/copy_as_gfm_spec.js index b8155144e2a..efbe09a10a2 100644 --- a/spec/javascripts/behaviors/copy_as_gfm_spec.js +++ b/spec/javascripts/behaviors/copy_as_gfm_spec.js @@ -1,4 +1,4 @@ -import { CopyAsGFM } from '~/behaviors/copy_as_gfm'; +import { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; describe('CopyAsGFM', () => { describe('CopyAsGFM.pasteGFM', () => { diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js index d5300d9c63d..c37c62c63dd 100644 --- a/spec/javascripts/behaviors/quick_submit_spec.js +++ b/spec/javascripts/behaviors/quick_submit_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import '~/behaviors/quick_submit'; describe('Quick Submit behavior', () => { diff --git a/spec/javascripts/behaviors/requires_input_spec.js b/spec/javascripts/behaviors/requires_input_spec.js index e500bbe750f..a434949b9da 100644 --- a/spec/javascripts/behaviors/requires_input_spec.js +++ b/spec/javascripts/behaviors/requires_input_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import '~/behaviors/requires_input'; describe('requiresInput', () => { diff --git a/spec/javascripts/blob/blob_file_dropzone_spec.js b/spec/javascripts/blob/blob_file_dropzone_spec.js index 47de63e6690..0b1de504435 100644 --- a/spec/javascripts/blob/blob_file_dropzone_spec.js +++ b/spec/javascripts/blob/blob_file_dropzone_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import BlobFileDropzone from '~/blob/blob_file_dropzone'; describe('BlobFileDropzone', () => { @@ -27,7 +28,7 @@ describe('BlobFileDropzone', () => { name: 'some-file.jpg', type: 'jpg', }; - const fakeEvent = jQuery.Event('drop', { + const fakeEvent = $.Event('drop', { dataTransfer: { files: [file] }, }); diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js index 892411a6a40..f920c4ca945 100644 --- a/spec/javascripts/blob/viewer/index_spec.js +++ b/spec/javascripts/blob/viewer/index_spec.js @@ -1,4 +1,6 @@ /* eslint-disable no-new */ + +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import BlobViewer from '~/blob/viewer/index'; import axios from '~/lib/utils/axios_utils'; diff --git a/spec/javascripts/bootstrap_jquery_spec.js b/spec/javascripts/bootstrap_jquery_spec.js index 48994b7c523..0fd6f9dc810 100644 --- a/spec/javascripts/bootstrap_jquery_spec.js +++ b/spec/javascripts/bootstrap_jquery_spec.js @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren, no-var */ +import $ from 'jquery'; import '~/commons/bootstrap'; (function() { diff --git a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js index 270f925e699..2fa50975f0f 100644 --- a/spec/javascripts/ci_variable_list/ci_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/ci_variable_list_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import VariableList from '~/ci_variable_list/ci_variable_list'; import getSetTimeoutPromise from 'spec/helpers/set_timeout_promise_helper'; diff --git a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js index eb508a7f059..1ea8d86cb7e 100644 --- a/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js +++ b/spec/javascripts/ci_variable_list/native_form_variable_list_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; describe('NativeFormVariableList', () => { diff --git a/spec/javascripts/commits_spec.js b/spec/javascripts/commits_spec.js index 1daccc8dd02..977298b9221 100644 --- a/spec/javascripts/commits_spec.js +++ b/spec/javascripts/commits_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import 'vendor/jquery.endless-scroll'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; diff --git a/spec/javascripts/create_item_dropdown_spec.js b/spec/javascripts/create_item_dropdown_spec.js index 143137c23ec..ee26122be12 100644 --- a/spec/javascripts/create_item_dropdown_spec.js +++ b/spec/javascripts/create_item_dropdown_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import CreateItemDropdown from '~/create_item_dropdown'; const DROPDOWN_ITEM_DATA = [{ diff --git a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js index 1b1f28f3ddb..480c138b9db 100644 --- a/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js +++ b/spec/javascripts/feature_highlight/feature_highlight_helper_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import { diff --git a/spec/javascripts/feature_highlight/feature_highlight_spec.js b/spec/javascripts/feature_highlight/feature_highlight_spec.js index f3f80cb3771..d2dd39d49d1 100644 --- a/spec/javascripts/feature_highlight/feature_highlight_spec.js +++ b/spec/javascripts/feature_highlight/feature_highlight_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import * as featureHighlightHelper from '~/feature_highlight/feature_highlight_helper'; import * as featureHighlight from '~/feature_highlight/feature_highlight'; import axios from '~/lib/utils/axios_utils'; diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js index 71c14582329..8c5a0961a02 100644 --- a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js @@ -1,8 +1,9 @@ +import $ from 'jquery'; import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; describe('Filtered Search Dropdown Manager', () => { beforeEach(() => { - spyOn(jQuery, 'ajax'); + spyOn($, 'ajax'); }); describe('addWordToInput', () => { diff --git a/spec/javascripts/gfm_auto_complete_spec.js b/spec/javascripts/gfm_auto_complete_spec.js index 50a587ef351..dc0a5bc275c 100644 --- a/spec/javascripts/gfm_auto_complete_spec.js +++ b/spec/javascripts/gfm_auto_complete_spec.js @@ -1,5 +1,6 @@ /* eslint no-param-reassign: "off" */ +import $ from 'jquery'; import GfmAutoComplete from '~/gfm_auto_complete'; import 'vendor/jquery.caret'; diff --git a/spec/javascripts/gl_dropdown_spec.js b/spec/javascripts/gl_dropdown_spec.js index 67b854f61c0..0e4a7017406 100644 --- a/spec/javascripts/gl_dropdown_spec.js +++ b/spec/javascripts/gl_dropdown_spec.js @@ -1,5 +1,6 @@ /* eslint-disable comma-dangle, no-param-reassign, no-unused-expressions, max-len */ +import $ from 'jquery'; import '~/gl_dropdown'; import '~/lib/utils/common_utils'; import * as urlUtils from '~/lib/utils/url_utility'; diff --git a/spec/javascripts/gl_field_errors_spec.js b/spec/javascripts/gl_field_errors_spec.js index 2779686a6f5..4e93fd91751 100644 --- a/spec/javascripts/gl_field_errors_spec.js +++ b/spec/javascripts/gl_field_errors_spec.js @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren, arrow-body-style */ +import $ from 'jquery'; import GlFieldErrors from '~/gl_field_errors'; describe('GL Style Field Errors', function() { diff --git a/spec/javascripts/gl_form_spec.js b/spec/javascripts/gl_form_spec.js index 9c1fc0fda9e..74383f901b2 100644 --- a/spec/javascripts/gl_form_spec.js +++ b/spec/javascripts/gl_form_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import autosize from 'autosize'; import GLForm from '~/gl_form'; import '~/lib/utils/text_utility'; diff --git a/spec/javascripts/groups/components/app_spec.js b/spec/javascripts/groups/components/app_spec.js index 46c7b9f54f2..d8428bd0e08 100644 --- a/spec/javascripts/groups/components/app_spec.js +++ b/spec/javascripts/groups/components/app_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import * as utils from '~/lib/utils/url_utility'; diff --git a/spec/javascripts/header_spec.js b/spec/javascripts/header_spec.js index 2443ffd48f3..16ac438f7ac 100644 --- a/spec/javascripts/header_spec.js +++ b/spec/javascripts/header_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import initTodoToggle from '~/header'; describe('Header', function () { diff --git a/spec/javascripts/ide/components/changed_file_icon_spec.js b/spec/javascripts/ide/components/changed_file_icon_spec.js new file mode 100644 index 00000000000..987aea7befc --- /dev/null +++ b/spec/javascripts/ide/components/changed_file_icon_spec.js @@ -0,0 +1,45 @@ +import Vue from 'vue'; +import changedFileIcon from '~/ide/components/changed_file_icon.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('IDE changed file icon', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(changedFileIcon); + + vm = createComponent(component, { + file: { + tempFile: false, + }, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('changedIcon', () => { + it('equals file-modified when not a temp file', () => { + expect(vm.changedIcon).toBe('file-modified'); + }); + + it('equals file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIcon).toBe('file-addition'); + }); + }); + + describe('changedIconClass', () => { + it('includes multi-file-modified when not a temp file', () => { + expect(vm.changedIconClass).toContain('multi-file-modified'); + }); + + it('includes multi-file-addition when a temp file', () => { + vm.file.tempFile = true; + + expect(vm.changedIconClass).toContain('multi-file-addition'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/actions_spec.js b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js new file mode 100644 index 00000000000..144e78d14b5 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/actions_spec.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import commitActions from '~/ide/components/commit_sidebar/actions.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar actions', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(commitActions); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.currentBranchId = 'master'; + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 groups', () => { + expect(vm.$el.querySelectorAll('input[type="radio"]').length).toBe(3); + }); + + it('renders current branch text', () => { + expect(vm.$el.textContent).toContain('Commit to master branch'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js new file mode 100644 index 00000000000..5b402886b55 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_collapsed_spec.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list collapsed', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(listCollapsed); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.changedFiles.push(file('file1'), file('file2')); + vm.$store.state.changedFiles[0].tempFile = true; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders added & modified files count', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1'); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js new file mode 100644 index 00000000000..15b66952d99 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -0,0 +1,85 @@ +import Vue from 'vue'; +import listItem from '~/ide/components/commit_sidebar/list_item.vue'; +import router from '~/ide/ide_router'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list item', () => { + let vm; + let f; + + beforeEach(() => { + const Component = Vue.extend(listItem); + + f = file('test-file'); + + vm = mountComponent(Component, { + file: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file path', () => { + expect( + vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim(), + ).toBe(f.path); + }); + + it('calls discardFileChanges when clicking discard button', () => { + spyOn(vm, 'discardFileChanges'); + + vm.$el.querySelector('.multi-file-discard-btn').click(); + + expect(vm.discardFileChanges).toHaveBeenCalled(); + }); + + it('opens a closed file in the editor when clicking the file path', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.openFileInEditor).toHaveBeenCalled(); + expect(router.push).toHaveBeenCalled(); + }); + + it('calls updateViewer with diff when clicking file', () => { + spyOn(vm, 'openFileInEditor').and.callThrough(); + spyOn(vm, 'updateViewer'); + spyOn(router, 'push'); + + vm.$el.querySelector('.multi-file-commit-list-path').click(); + + expect(vm.updateViewer).toHaveBeenCalledWith('diff'); + }); + + describe('computed', () => { + describe('iconName', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconName).toBe('file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconName).toBe('file-addition'); + }); + }); + + describe('iconClass', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconClass).toContain('multi-file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconClass).toContain('multi-file-addition'); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/list_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_spec.js new file mode 100644 index 00000000000..a62c0a28340 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/list_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import commitSidebarList from '~/ide/components/commit_sidebar/list.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(commitSidebarList); + + vm = createComponentWithStore(Component, store, { + title: 'Staged', + fileList: [], + }); + + vm.$store.state.rightPanelCollapsed = false; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('with a list of files', () => { + beforeEach(done => { + const f = file('file name'); + f.changed = true; + vm.fileList.push(f); + + Vue.nextTick(done); + }); + + it('renders list', () => { + expect(vm.$el.querySelectorAll('li').length).toBe(1); + }); + }); + + 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('.help-block')).toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js new file mode 100644 index 00000000000..4e8243439f3 --- /dev/null +++ b/spec/javascripts/ide/components/commit_sidebar/radio_group_spec.js @@ -0,0 +1,130 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from 'spec/ide/helpers'; + +describe('IDE commit sidebar radio group', () => { + let vm; + + beforeEach(done => { + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '2'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('uses label if present', () => { + expect(vm.$el.textContent).toContain('test'); + }); + + it('uses slot if label is not present', done => { + vm.$destroy(); + + vm = new Vue({ + components: { + radioGroup, + }, + store, + template: ` + <radio-group + value="1" + > + Testing slot + </radio-group> + `, + }); + + vm.$mount(); + + Vue.nextTick(() => { + expect(vm.$el.textContent).toContain('Testing slot'); + + done(); + }); + }); + + it('updates store when changing radio button', done => { + vm.$el.querySelector('input').dispatchEvent(new Event('change')); + + Vue.nextTick(() => { + expect(store.state.commit.commitAction).toBe('1'); + + done(); + }); + }); + + it('renders helpText tooltip', done => { + vm.helpText = 'help text'; + + Vue.nextTick(() => { + const help = vm.$el.querySelector('.help-block'); + + expect(help).not.toBeNull(); + expect(help.getAttribute('data-original-title')).toBe('help text'); + + done(); + }); + }); + + describe('with input', () => { + beforeEach(done => { + vm.$destroy(); + + const Component = Vue.extend(radioGroup); + + store.state.commit.commitAction = '1'; + + vm = createComponentWithStore(Component, store, { + value: '1', + label: 'test', + checked: true, + showInput: true, + }); + + vm.$mount(); + + Vue.nextTick(done); + }); + + it('renders input box when commitAction matches value', () => { + expect(vm.$el.querySelector('.form-control')).not.toBeNull(); + }); + + it('hides input when commitAction doesnt match value', done => { + store.state.commit.commitAction = '2'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.form-control')).toBeNull(); + done(); + }); + }); + + it('updates branch name in store on input', done => { + const input = vm.$el.querySelector('.form-control'); + input.value = 'testing-123'; + input.dispatchEvent(new Event('input')); + + Vue.nextTick(() => { + expect(store.state.commit.newBranchName).toBe('testing-123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_context_bar_spec.js b/spec/javascripts/ide/components/ide_context_bar_spec.js new file mode 100644 index 00000000000..e17b051f137 --- /dev/null +++ b/spec/javascripts/ide/components/ide_context_bar_spec.js @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000000..9f6cb459f3b --- /dev/null +++ b/spec/javascripts/ide/components/ide_external_links_spec.js @@ -0,0 +1,43 @@ +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 new file mode 100644 index 00000000000..657682cb39c --- /dev/null +++ b/spec/javascripts/ide/components/ide_project_tree_spec.js @@ -0,0 +1,39 @@ +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 new file mode 100644 index 00000000000..e0fbc90ca61 --- /dev/null +++ b/spec/javascripts/ide/components/ide_repo_tree_spec.js @@ -0,0 +1,43 @@ +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_side_bar_spec.js b/spec/javascripts/ide/components/ide_side_bar_spec.js new file mode 100644 index 00000000000..699dae1ce2f --- /dev/null +++ b/spec/javascripts/ide/components/ide_side_bar_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import ideSidebar from '~/ide/components/ide_side_bar.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../helpers'; + +describe('IdeSidebar', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ideSidebar); + + vm = createComponentWithStore(Component, store).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a sidebar', () => { + 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); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/ide_spec.js b/spec/javascripts/ide/components/ide_spec.js new file mode 100644 index 00000000000..5bd890094cc --- /dev/null +++ b/spec/javascripts/ide/components/ide_spec.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +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'; + +describe('ide component', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(ide); + + vm = createComponentWithStore(Component, store, { + emptyStateSvgPath: 'svg', + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'svg', + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('does not render panel right when no files open', () => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + }); + + it('renders panel right when files are open', done => { + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [file()], + }; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.panel-right')).toBeNull(); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/index_spec.js b/spec/javascripts/ide/components/new_dropdown/index_spec.js new file mode 100644 index 00000000000..e08abe7d849 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/index_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import newDropdown from '~/ide/components/new_dropdown/index.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; + +describe('new dropdown component', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(newDropdown); + + vm = createComponentWithStore(component, store, { + branch: 'master', + path: '', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.path = ''; + vm.$store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders new file, upload and new directory links', () => { + expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file'); + expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe( + 'Upload file', + ); + expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe( + 'New directory', + ); + }); + + describe('createNewItem', () => { + it('sets modalType to blob when new file is clicked', () => { + vm.$el.querySelectorAll('a')[0].click(); + + expect(vm.modalType).toBe('blob'); + }); + + it('sets modalType to tree when new directory is clicked', () => { + vm.$el.querySelectorAll('a')[2].click(); + + expect(vm.modalType).toBe('tree'); + }); + + it('opens modal when link is clicked', done => { + vm.$el.querySelectorAll('a')[0].click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.modal')).not.toBeNull(); + + done(); + }); + }); + }); + + describe('hideModal', () => { + beforeAll(done => { + vm.openModal = true; + Vue.nextTick(done); + }); + + it('closes modal after toggling', done => { + vm.hideModal(); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.modal')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/modal_spec.js b/spec/javascripts/ide/components/new_dropdown/modal_spec.js new file mode 100644 index 00000000000..a6e1e5a0d35 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/modal_spec.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; +import modal from '~/ide/components/new_dropdown/modal.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new file modal component', () => { + const Component = Vue.extend(modal); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + ['tree', 'blob'].forEach(type => { + describe(type, () => { + beforeEach(() => { + vm = createComponent(Component, { + type, + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + }); + + it(`sets modal title as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe( + `Create new ${title}`, + ); + }); + + it(`sets button label as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe( + `Create ${title}`, + ); + }); + + it(`sets form label as ${type}`, () => { + const title = type === 'tree' ? 'Directory' : 'File'; + + expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe( + `${title} name`, + ); + }); + + describe('createEntryInStore', () => { + it('$emits create', () => { + spyOn(vm, '$emit'); + + vm.createEntryInStore(); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + branchId: 'master', + name: 'testing', + type, + }); + }); + }); + }); + }); + + it('focuses field on mount', () => { + document.body.innerHTML += '<div class="js-test"></div>'; + + vm = createComponent( + Component, + { + type: 'tree', + branchId: 'master', + path: '', + }, + '.js-test', + ); + + expect(document.activeElement).toBe(vm.$refs.fieldName); + + vm.$el.remove(); + }); +}); diff --git a/spec/javascripts/ide/components/new_dropdown/upload_spec.js b/spec/javascripts/ide/components/new_dropdown/upload_spec.js new file mode 100644 index 00000000000..2bc5d701601 --- /dev/null +++ b/spec/javascripts/ide/components/new_dropdown/upload_spec.js @@ -0,0 +1,87 @@ +import Vue from 'vue'; +import upload from '~/ide/components/new_dropdown/upload.vue'; +import createComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('new dropdown upload', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(upload); + + vm = createComponent(Component, { + branchId: 'master', + path: '', + }); + + vm.entryName = 'testing'; + + spyOn(vm, '$emit'); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('readFile', () => { + beforeEach(() => { + spyOn(FileReader.prototype, 'readAsText'); + spyOn(FileReader.prototype, 'readAsDataURL'); + }); + + it('calls readAsText for text files', () => { + const file = { + type: 'text/html', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsText).toHaveBeenCalledWith(file); + }); + + it('calls readAsDataURL for non-text files', () => { + const file = { + type: 'images/png', + }; + + vm.readFile(file); + + expect(FileReader.prototype.readAsDataURL).toHaveBeenCalledWith(file); + }); + }); + + describe('createFile', () => { + const target = { + result: 'content', + }; + const binaryTarget = { + result: 'base64,base64content', + }; + const file = { + name: 'file', + }; + + it('creates new file', () => { + vm.createFile(target, file, true); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: target.result, + base64: false, + }); + }); + + it('splits content on base64 if binary', () => { + vm.createFile(binaryTarget, file, false); + + expect(vm.$emit).toHaveBeenCalledWith('create', { + name: file.name, + branchId: 'master', + type: 'blob', + content: binaryTarget.result.split('base64,')[1], + base64: true, + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js new file mode 100644 index 00000000000..113ade269e9 --- /dev/null +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -0,0 +1,173 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +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', () => { + let vm; + + function createComponent() { + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'svg', + committedStateSvgPath: 'commitsvg', + }); + + vm.$store.state.currentProjectId = 'abcproject'; + vm.$store.state.currentBranchId = 'master'; + vm.$store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + + vm.$store.state.rightPanelCollapsed = false; + vm.$store.state.currentBranch = 'master'; + vm.$store.state.changedFiles = [file('file1'), file('file2')]; + vm.$store.state.changedFiles.forEach(f => + Object.assign(f, { + changed: true, + content: 'testing', + }), + ); + + return vm.$mount(); + } + + beforeEach(done => { + vm = createComponent(); + + spyOn(service, 'getTreeData').and.returnValue( + Promise.resolve({ + headers: { + 'page-title': 'test', + }, + json: () => + Promise.resolve({ + last_commit_path: 'last_commit_path', + parent_tree_url: 'parent_tree_url', + path: '/', + trees: [{ name: 'tree' }], + blobs: [{ name: 'blob' }], + submodules: [{ name: 'submodule' }], + }), + }), + ); + + Vue.nextTick(done); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + describe('empty Stage', () => { + it('renders no changes text', () => { + resetStore(vm.$store); + const Component = Vue.extend(repoCommitSection); + + vm = createComponentWithStore(Component, store, { + noChangesStateSvgPath: 'nochangessvg', + committedStateSvgPath: 'svg', + }).$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'); + + expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); + expect(changedFileElements.length).toEqual(2); + + changedFileElements.forEach((changedFile, i) => { + expect(changedFile.textContent.trim()).toContain( + vm.$store.state.changedFiles[i].path, + ); + }); + + expect(submitCommit.disabled).toBeTruthy(); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); + }); + + 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-default'), + ).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-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'); + }); + + 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); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js new file mode 100644 index 00000000000..ae657e8c881 --- /dev/null +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -0,0 +1,137 @@ +import Vue from 'vue'; +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 { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file, resetStore } from '../helpers'; + +describe('RepoEditor', () => { + let vm; + + beforeEach(done => { + const f = file(); + const RepoEditor = Vue.extend(repoEditor); + + vm = createComponentWithStore(RepoEditor, store, { + file: f, + }); + + f.active = true; + f.tempFile = true; + f.html = 'testing'; + vm.$store.state.openFiles.push(f); + vm.$store.state.entries[f.path] = f; + vm.monaco = true; + + vm.$mount(); + + monacoLoader(['vs/editor/editor.main'], () => { + setTimeout(done, 0); + }); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + + Editor.editorInstance.modelManager.dispose(); + }); + + it('renders an ide container', done => { + Vue.nextTick(() => { + expect(vm.shouldHideEditor).toBeFalsy(); + + done(); + }); + }); + + describe('when open file is binary and not raw', () => { + beforeEach(done => { + vm.file.binary = true; + + vm.$nextTick(done); + }); + + it('does not render the IDE', () => { + expect(vm.shouldHideEditor).toBeTruthy(); + }); + + it('shows activeFile html', () => { + expect(vm.$el.textContent).toContain('testing'); + }); + }); + + describe('createEditorInstance', () => { + it('calls createInstance when viewer is editor', done => { + spyOn(vm.editor, 'createInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createInstance).toHaveBeenCalled(); + + done(); + }); + }); + + it('calls createDiffInstance when viewer is diff', done => { + vm.$store.state.viewer = 'diff'; + + spyOn(vm.editor, 'createDiffInstance'); + + vm.createEditorInstance(); + + vm.$nextTick(() => { + expect(vm.editor.createDiffInstance).toHaveBeenCalled(); + + done(); + }); + }); + }); + + describe('setupEditor', () => { + it('creates new model', () => { + spyOn(vm.editor, 'createModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.createModel).toHaveBeenCalledWith(vm.file); + expect(vm.model).not.toBeNull(); + }); + + it('attaches model to editor', () => { + spyOn(vm.editor, 'attachModel').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.attachModel).toHaveBeenCalledWith(vm.model); + }); + + it('adds callback methods', () => { + spyOn(vm.editor, 'onPositionChange').and.callThrough(); + + Editor.editorInstance.modelManager.dispose(); + + vm.setupEditor(); + + expect(vm.editor.onPositionChange).toHaveBeenCalled(); + expect(vm.model.events.size).toBe(1); + }); + + it('updates state when model content changed', done => { + vm.model.setValue('testing 123'); + + setTimeout(() => { + expect(vm.file.content).toBe('testing 123'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_buttons_spec.js b/spec/javascripts/ide/components/repo_file_buttons_spec.js new file mode 100644 index 00000000000..c86bdb132b4 --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_buttons_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import repoFileButtons from '~/ide/components/repo_file_buttons.vue'; +import createVueComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFileButtons', () => { + const activeFile = file(); + let vm; + + function createComponent() { + const RepoFileButtons = Vue.extend(repoFileButtons); + + activeFile.rawPath = 'test'; + activeFile.blamePath = 'test'; + activeFile.commitsPath = 'test'; + + return createVueComponent(RepoFileButtons, { + file: activeFile, + }); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders Raw, Blame, History, Permalink and Preview toggle', done => { + vm = createComponent(); + + vm.$nextTick(() => { + const raw = vm.$el.querySelector('.raw'); + const blame = vm.$el.querySelector('.blame'); + const history = vm.$el.querySelector('.history'); + + expect(raw.href).toMatch(`/${activeFile.rawPath}`); + expect(raw.textContent.trim()).toEqual('Raw'); + expect(blame.href).toMatch(`/${activeFile.blamePath}`); + expect(blame.textContent.trim()).toEqual('Blame'); + expect(history.href).toMatch(`/${activeFile.commitsPath}`); + expect(history.textContent.trim()).toEqual('History'); + expect(vm.$el.querySelector('.permalink').textContent.trim()).toEqual( + 'Permalink', + ); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_file_spec.js b/spec/javascripts/ide/components/repo_file_spec.js new file mode 100644 index 00000000000..ff391cb4351 --- /dev/null +++ b/spec/javascripts/ide/components/repo_file_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import repoFile from '~/ide/components/repo_file.vue'; +import router from '~/ide/ide_router'; +import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoFile', () => { + let vm; + + function createComponent(propsData) { + const RepoFile = Vue.extend(repoFile); + + vm = createComponentWithStore(RepoFile, store, propsData); + + vm.$mount(); + } + + afterEach(() => { + vm.$destroy(); + }); + + it('renders link, icon and name', () => { + createComponent({ + file: file('t4'), + level: 0, + }); + + const name = vm.$el.querySelector('.ide-file-name'); + + expect(name.href).toMatch(''); + expect(name.textContent.trim()).toEqual(vm.file.name); + }); + + it('fires clickFile when the link is clicked', done => { + spyOn(router, 'push'); + createComponent({ + file: file('t3'), + level: 0, + }); + + vm.$el.querySelector('.file-name').click(); + + setTimeout(() => { + expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`); + + done(); + }); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + createComponent({ + file: f, + level: 0, + }); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect( + vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset + .originalTitle, + ).toContain('Locked by testuser'); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_loading_file_spec.js b/spec/javascripts/ide/components/repo_loading_file_spec.js new file mode 100644 index 00000000000..8f9644216bc --- /dev/null +++ b/spec/javascripts/ide/components/repo_loading_file_spec.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import repoLoadingFile from '~/ide/components/repo_loading_file.vue'; +import { resetStore } from '../helpers'; + +describe('RepoLoadingFile', () => { + let vm; + + function createComponent() { + const RepoLoadingFile = Vue.extend(repoLoadingFile); + + return new RepoLoadingFile({ + store, + }).$mount(); + } + + function assertLines(lines) { + lines.forEach((line, n) => { + const index = n + 1; + expect(line.classList.contains(`skeleton-line-${index}`)).toBeTruthy(); + }); + } + + function assertColumns(columns) { + columns.forEach(column => { + const container = column.querySelector('.animation-container'); + const lines = [...container.querySelectorAll(':scope > div')]; + + expect(container).toBeTruthy(); + expect(lines.length).toEqual(6); + assertLines(lines); + }); + } + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders 3 columns of animated LoC', () => { + vm = createComponent(); + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(3); + assertColumns(columns); + }); + + it('renders 1 column of animated LoC if isMini', done => { + vm = createComponent(); + vm.$store.state.leftPanelCollapsed = true; + vm.$store.state.openFiles.push('test'); + + vm.$nextTick(() => { + const columns = [...vm.$el.querySelectorAll('td')]; + + expect(columns.length).toEqual(1); + assertColumns(columns); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tab_spec.js b/spec/javascripts/ide/components/repo_tab_spec.js new file mode 100644 index 00000000000..ddb5204e3a7 --- /dev/null +++ b/spec/javascripts/ide/components/repo_tab_spec.js @@ -0,0 +1,165 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import repoTab from '~/ide/components/repo_tab.vue'; +import router from '~/ide/ide_router'; +import { file, resetStore } from '../helpers'; + +describe('RepoTab', () => { + let vm; + + function createComponent(propsData) { + const RepoTab = Vue.extend(repoTab); + + return new RepoTab({ + store, + propsData, + }).$mount(); + } + + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + vm.$destroy(); + + resetStore(vm.$store); + }); + + it('renders a close link and a name link', () => { + vm = createComponent({ + tab: file(), + }); + vm.$store.state.openFiles.push(vm.tab); + const close = vm.$el.querySelector('.multi-file-tab-close'); + const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`); + + expect(close.innerHTML).toContain('#close'); + expect(name.textContent.trim()).toEqual(vm.tab.name); + }); + + it('fires clickFile when the link is clicked', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'clickFile'); + + vm.$el.click(); + + expect(vm.clickFile).toHaveBeenCalledWith(vm.tab); + }); + + it('calls closeFile when clicking close button', () => { + vm = createComponent({ + tab: file(), + }); + + spyOn(vm, 'closeFile'); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + expect(vm.closeFile).toHaveBeenCalledWith(vm.tab.path); + }); + + it('changes icon on hover', done => { + const tab = file(); + tab.changed = true; + vm = createComponent({ + tab, + }); + + vm.$el.dispatchEvent(new Event('mouseover')); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.multi-file-modified')).toBeNull(); + + vm.$el.dispatchEvent(new Event('mouseout')); + }) + .then(Vue.nextTick) + .then(() => { + expect(vm.$el.querySelector('.multi-file-modified')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + + describe('locked file', () => { + let f; + + beforeEach(() => { + f = file('locked file'); + f.file_lock = { + user: { + name: 'testuser', + updated_at: new Date(), + }, + }; + + vm = createComponent({ + tab: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders lock icon', () => { + expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + }); + + it('renders a tooltip', () => { + expect( + vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle, + ).toContain('Locked by testuser'); + }); + }); + + describe('methods', () => { + describe('closeTab', () => { + it('closes tab if file has changed', done => { + const tab = file(); + tab.changed = true; + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.changedFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + expect(vm.$store.state.changedFiles.length).toBe(1); + + done(); + }); + }); + + it('closes tab when clicking close btn', done => { + const tab = file('lose'); + tab.opened = true; + vm = createComponent({ + tab, + }); + vm.$store.state.openFiles.push(tab); + vm.$store.state.entries[tab.path] = tab; + vm.$store.dispatch('setFileActive', tab.path); + + vm.$el.querySelector('.multi-file-tab-close').click(); + + vm.$nextTick(() => { + expect(tab.opened).toBeFalsy(); + + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_tabs_spec.js b/spec/javascripts/ide/components/repo_tabs_spec.js new file mode 100644 index 00000000000..ceb0416aff8 --- /dev/null +++ b/spec/javascripts/ide/components/repo_tabs_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; +import repoTabs from '~/ide/components/repo_tabs.vue'; +import createComponent from '../../helpers/vue_mount_component_helper'; +import { file } from '../helpers'; + +describe('RepoTabs', () => { + const openedFiles = [file('open1'), file('open2')]; + const RepoTabs = Vue.extend(repoTabs); + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('renders a list of tabs', done => { + vm = createComponent(RepoTabs, { + files: openedFiles, + viewer: 'editor', + hasChanges: false, + }); + openedFiles[0].active = true; + + vm.$nextTick(() => { + 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); + + 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, + }, + '#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/helpers.js b/spec/javascripts/ide/helpers.js new file mode 100644 index 00000000000..98db6defc7a --- /dev/null +++ b/spec/javascripts/ide/helpers.js @@ -0,0 +1,22 @@ +import { decorateData } from '~/ide/stores/utils'; +import state from '~/ide/stores/state'; +import commitState from '~/ide/stores/modules/commit/state'; + +export const resetStore = store => { + const newState = { + ...state(), + commit: commitState(), + }; + store.replaceState(newState); +}; + +export const file = (name = 'name', id = name, type = '') => + decorateData({ + id, + type, + icon: 'icon', + url: 'url', + name, + path: name, + lastCommit: {}, + }); diff --git a/spec/javascripts/ide/lib/common/disposable_spec.js b/spec/javascripts/ide/lib/common/disposable_spec.js new file mode 100644 index 00000000000..af12ca15369 --- /dev/null +++ b/spec/javascripts/ide/lib/common/disposable_spec.js @@ -0,0 +1,44 @@ +import Disposable from '~/ide/lib/common/disposable'; + +describe('Multi-file editor library disposable class', () => { + let instance; + let disposableClass; + + beforeEach(() => { + instance = new Disposable(); + + disposableClass = { + dispose: jasmine.createSpy('dispose'), + }; + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('add', () => { + it('adds disposable classes', () => { + instance.add(disposableClass); + + expect(instance.disposers.size).toBe(1); + }); + }); + + describe('dispose', () => { + beforeEach(() => { + instance.add(disposableClass); + }); + + it('calls dispose on all cached disposers', () => { + instance.dispose(); + + expect(disposableClass.dispose).toHaveBeenCalled(); + }); + + it('clears cached disposers', () => { + instance.dispose(); + + expect(instance.disposers.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_manager_spec.js b/spec/javascripts/ide/lib/common/model_manager_spec.js new file mode 100644 index 00000000000..4381f6fcfd0 --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_manager_spec.js @@ -0,0 +1,129 @@ +/* global monaco */ +import eventHub from '~/ide/eventhub'; +import monacoLoader from '~/ide/monaco_loader'; +import ModelManager from '~/ide/lib/common/model_manager'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model manager', () => { + let instance; + + beforeEach(done => { + monacoLoader(['vs/editor/editor.main'], () => { + instance = new ModelManager(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + }); + + describe('addModel', () => { + it('caches model', () => { + instance.addModel(file()); + + expect(instance.models.size).toBe(1); + }); + + it('caches model by file path', () => { + instance.addModel(file('path-name')); + + expect(instance.models.keys().next().value).toBe('path-name'); + }); + + it('adds model into disposable', () => { + spyOn(instance.disposable, 'add').and.callThrough(); + + instance.addModel(file()); + + expect(instance.disposable.add).toHaveBeenCalled(); + }); + + it('returns cached model', () => { + spyOn(instance.models, 'get').and.callThrough(); + + instance.addModel(file()); + instance.addModel(file()); + + expect(instance.models.get).toHaveBeenCalled(); + }); + + it('adds eventHub listener', () => { + const f = file(); + spyOn(eventHub, '$on').and.callThrough(); + + instance.addModel(f); + + expect(eventHub.$on).toHaveBeenCalledWith( + `editor.update.model.dispose.${f.path}`, + jasmine.anything(), + ); + }); + }); + + describe('hasCachedModel', () => { + it('returns false when no models exist', () => { + expect(instance.hasCachedModel('path')).toBeFalsy(); + }); + + it('returns true when model exists', () => { + instance.addModel(file('path-name')); + + expect(instance.hasCachedModel('path-name')).toBeTruthy(); + }); + }); + + describe('getModel', () => { + it('returns cached model', () => { + instance.addModel(file('path-name')); + + expect(instance.getModel('path-name')).not.toBeNull(); + }); + }); + + describe('removeCachedModel', () => { + let f; + + beforeEach(() => { + f = file(); + + instance.addModel(f); + }); + + it('clears cached model', () => { + instance.removeCachedModel(f); + + expect(instance.models.size).toBe(0); + }); + + it('removes eventHub listener', () => { + spyOn(eventHub, '$off').and.callThrough(); + + instance.removeCachedModel(f); + + expect(eventHub.$off).toHaveBeenCalledWith( + `editor.update.model.dispose.${f.path}`, + jasmine.anything(), + ); + }); + }); + + describe('dispose', () => { + it('clears cached models', () => { + instance.addModel(file()); + + instance.dispose(); + + expect(instance.models.size).toBe(0); + }); + + it('calls disposable dispose', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/common/model_spec.js b/spec/javascripts/ide/lib/common/model_spec.js new file mode 100644 index 00000000000..adc6a93c06b --- /dev/null +++ b/spec/javascripts/ide/lib/common/model_spec.js @@ -0,0 +1,113 @@ +/* global monaco */ +import eventHub from '~/ide/eventhub'; +import monacoLoader from '~/ide/monaco_loader'; +import Model from '~/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library model', () => { + let model; + + beforeEach(done => { + spyOn(eventHub, '$on').and.callThrough(); + + monacoLoader(['vs/editor/editor.main'], () => { + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + }); + + it('creates original model & new model', () => { + expect(model.originalModel).not.toBeNull(); + expect(model.model).not.toBeNull(); + }); + + it('adds eventHub listener', () => { + expect(eventHub.$on).toHaveBeenCalledWith( + `editor.update.model.dispose.${model.file.path}`, + jasmine.anything(), + ); + }); + + describe('path', () => { + it('returns file path', () => { + expect(model.path).toBe('path'); + }); + }); + + describe('getModel', () => { + it('returns model', () => { + expect(model.getModel()).toBe(model.model); + }); + }); + + describe('getOriginalModel', () => { + it('returns original model', () => { + expect(model.getOriginalModel()).toBe(model.originalModel); + }); + }); + + describe('setValue', () => { + it('updates models value', () => { + model.setValue('testing 123'); + + expect(model.getModel().getValue()).toBe('testing 123'); + }); + }); + + describe('onChange', () => { + it('caches event by path', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + expect(model.events.keys().next().value).toBe('path'); + }); + + it('calls callback on change', done => { + const spy = jasmine.createSpy(); + model.onChange(spy); + + model.getModel().setValue('123'); + + setTimeout(() => { + expect(spy).toHaveBeenCalledWith(model, jasmine.anything()); + done(); + }); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(model.disposable, 'dispose').and.callThrough(); + + model.dispose(); + + expect(model.disposable.dispose).toHaveBeenCalled(); + }); + + it('clears events', () => { + model.onChange(() => {}); + + expect(model.events.size).toBe(1); + + model.dispose(); + + expect(model.events.size).toBe(0); + }); + + it('removes eventHub listener', () => { + spyOn(eventHub, '$off').and.callThrough(); + + model.dispose(); + + expect(eventHub.$off).toHaveBeenCalledWith( + `editor.update.model.dispose.${model.file.path}`, + jasmine.anything(), + ); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/decorations/controller_spec.js b/spec/javascripts/ide/lib/decorations/controller_spec.js new file mode 100644 index 00000000000..092170d086a --- /dev/null +++ b/spec/javascripts/ide/lib/decorations/controller_spec.js @@ -0,0 +1,139 @@ +/* global monaco */ +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; +import DecorationsController from '~/ide/lib/decorations/controller'; +import Model from '~/ide/lib/common/model'; +import { file } from '../../helpers'; + +describe('Multi-file editor library decorations controller', () => { + let editorInstance; + let controller; + let model; + + beforeEach(done => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + controller = new DecorationsController(editorInstance); + model = new Model(monaco, file('path')); + + done(); + }); + }); + + afterEach(() => { + model.dispose(); + editorInstance.dispose(); + controller.dispose(); + }); + + describe('getAllDecorationsForModel', () => { + it('returns empty array when no decorations exist for model', () => { + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations).toEqual([]); + }); + + it('returns decorations by model URL', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + const decorations = controller.getAllDecorationsForModel(model); + + expect(decorations[0]).toEqual({ decoration: 'decorationValue' }); + }); + }); + + describe('addDecorations', () => { + it('caches decorations in a new map', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + expect(controller.decorations.size).toBe(1); + }); + + it('does not create new cache model', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue2' }, + ]); + + expect(controller.decorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + expect(controller.decorations.size).toBe(1); + expect(controller.decorations.keys().next().value).toBe('path'); + }); + + it('calls decorate method', () => { + spyOn(controller, 'decorate'); + + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + expect(controller.decorate).toHaveBeenCalled(); + }); + }); + + describe('decorate', () => { + it('sets decorations on editor instance', () => { + spyOn(controller.editor.instance, 'deltaDecorations'); + + controller.decorate(model); + + expect(controller.editor.instance.deltaDecorations).toHaveBeenCalledWith( + [], + [], + ); + }); + + it('caches decorations', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.size).toBe(1); + }); + + it('caches decorations by model URL', () => { + spyOn(controller.editor.instance, 'deltaDecorations').and.returnValue([]); + + controller.decorate(model); + + expect(controller.editorDecorations.keys().next().value).toBe('path'); + }); + }); + + describe('dispose', () => { + it('clears cached decorations', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + controller.dispose(); + + expect(controller.decorations.size).toBe(0); + }); + + it('clears cached editorDecorations', () => { + controller.addDecorations(model, 'key', [ + { decoration: 'decorationValue' }, + ]); + + controller.dispose(); + + expect(controller.editorDecorations.size).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/controller_spec.js b/spec/javascripts/ide/lib/diff/controller_spec.js new file mode 100644 index 00000000000..c8f3e9f4830 --- /dev/null +++ b/spec/javascripts/ide/lib/diff/controller_spec.js @@ -0,0 +1,196 @@ +/* global monaco */ +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; +import ModelManager from '~/ide/lib/common/model_manager'; +import DecorationsController from '~/ide/lib/decorations/controller'; +import DirtyDiffController, { + getDiffChangeType, + getDecorator, +} from '~/ide/lib/diff/controller'; +import { computeDiff } from '~/ide/lib/diff/diff'; +import { file } from '../../helpers'; + +describe('Multi-file editor library dirty diff controller', () => { + let editorInstance; + let controller; + let modelManager; + let decorationsController; + let model; + + beforeEach(done => { + monacoLoader(['vs/editor/editor.main'], () => { + editorInstance = editor.create(monaco); + editorInstance.createInstance(document.createElement('div')); + + modelManager = new ModelManager(monaco); + decorationsController = new DecorationsController(editorInstance); + + model = modelManager.addModel(file('path')); + + controller = new DirtyDiffController(modelManager, decorationsController); + + done(); + }); + }); + + afterEach(() => { + controller.dispose(); + model.dispose(); + decorationsController.dispose(); + editorInstance.dispose(); + }); + + describe('getDiffChangeType', () => { + ['added', 'removed', 'modified'].forEach(type => { + it(`returns ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDiffChangeType(change)).toBe(type); + }); + }); + }); + + describe('getDecorator', () => { + ['added', 'removed', 'modified'].forEach(type => { + it(`returns with linesDecorationsClassName for ${type}`, () => { + const change = { + [type]: true, + }; + + expect(getDecorator(change).options.linesDecorationsClassName).toBe( + `dirty-diff dirty-diff-${type}`, + ); + }); + + it('returns with line numbers', () => { + const change = { + lineNumber: 1, + endLineNumber: 2, + [type]: true, + }; + + const range = getDecorator(change).range; + + expect(range.startLineNumber).toBe(1); + expect(range.endLineNumber).toBe(2); + expect(range.startColumn).toBe(1); + expect(range.endColumn).toBe(1); + }); + }); + }); + + describe('attachModel', () => { + it('adds change event callback', () => { + spyOn(model, 'onChange'); + + controller.attachModel(model); + + expect(model.onChange).toHaveBeenCalled(); + }); + + it('calls throttledComputeDiff on change', () => { + spyOn(controller, 'throttledComputeDiff'); + + controller.attachModel(model); + + model.getModel().setValue('123'); + + expect(controller.throttledComputeDiff).toHaveBeenCalled(); + }); + }); + + describe('computeDiff', () => { + it('posts to worker', () => { + spyOn(controller.dirtyDiffWorker, 'postMessage'); + + controller.computeDiff(model); + + expect(controller.dirtyDiffWorker.postMessage).toHaveBeenCalledWith({ + path: model.path, + originalContent: '', + newContent: '', + }); + }); + }); + + describe('reDecorate', () => { + it('calls decorations controller decorate', () => { + spyOn(controller.decorationsController, 'decorate'); + + controller.reDecorate(model); + + expect(controller.decorationsController.decorate).toHaveBeenCalledWith( + model, + ); + }); + }); + + describe('decorate', () => { + it('adds decorations into decorations controller', () => { + spyOn(controller.decorationsController, 'addDecorations'); + + controller.decorate({ data: { changes: [], path: 'path' } }); + + expect( + controller.decorationsController.addDecorations, + ).toHaveBeenCalledWith(model, 'dirtyDiff', jasmine.anything()); + }); + + it('adds decorations into editor', () => { + const spy = spyOn( + controller.decorationsController.editor.instance, + 'deltaDecorations', + ); + + controller.decorate({ + data: { changes: computeDiff('123', '1234'), path: 'path' }, + }); + + expect(spy).toHaveBeenCalledWith( + [], + [ + { + range: new monaco.Range(1, 1, 1, 1), + options: { + isWholeLine: true, + linesDecorationsClassName: 'dirty-diff dirty-diff-modified', + }, + }, + ], + ); + }); + }); + + describe('dispose', () => { + it('calls disposable dispose', () => { + spyOn(controller.disposable, 'dispose').and.callThrough(); + + controller.dispose(); + + expect(controller.disposable.dispose).toHaveBeenCalled(); + }); + + it('terminates worker', () => { + spyOn(controller.dirtyDiffWorker, 'terminate').and.callThrough(); + + controller.dispose(); + + expect(controller.dirtyDiffWorker.terminate).toHaveBeenCalled(); + }); + + it('removes worker event listener', () => { + spyOn( + controller.dirtyDiffWorker, + 'removeEventListener', + ).and.callThrough(); + + controller.dispose(); + + expect( + controller.dirtyDiffWorker.removeEventListener, + ).toHaveBeenCalledWith('message', jasmine.anything()); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/diff/diff_spec.js b/spec/javascripts/ide/lib/diff/diff_spec.js new file mode 100644 index 00000000000..57f3ac3d365 --- /dev/null +++ b/spec/javascripts/ide/lib/diff/diff_spec.js @@ -0,0 +1,80 @@ +import { computeDiff } from '~/ide/lib/diff/diff'; + +describe('Multi-file editor library diff calculator', () => { + describe('computeDiff', () => { + it('returns empty array if no changes', () => { + const diff = computeDiff('123', '123'); + + expect(diff).toEqual([]); + }); + + describe('modified', () => { + it('', () => { + const diff = computeDiff('123', '1234')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(2); + }); + }); + + describe('added', () => { + it('', () => { + const diff = computeDiff('123', '123\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123\n1234\n123')[0]; + + expect(diff.added).toBeTruthy(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeUndefined(); + expect(diff.lineNumber).toBe(3); + }); + }); + + describe('removed', () => { + it('', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeUndefined(); + expect(diff.removed).toBeTruthy(); + }); + + it('', () => { + const diff = computeDiff('123\n123\n123', '123\n123')[0]; + + expect(diff.added).toBeUndefined(); + expect(diff.modified).toBeTruthy(); + expect(diff.removed).toBeTruthy(); + expect(diff.lineNumber).toBe(2); + }); + }); + + it('includes line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.lineNumber).toBe(1); + }); + + it('includes end line number of change', () => { + const diff = computeDiff('123', '')[0]; + + expect(diff.endLineNumber).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_options_spec.js b/spec/javascripts/ide/lib/editor_options_spec.js new file mode 100644 index 00000000000..d149a883166 --- /dev/null +++ b/spec/javascripts/ide/lib/editor_options_spec.js @@ -0,0 +1,11 @@ +import editorOptions from '~/ide/lib/editor_options'; + +describe('Multi-file editor library editor options', () => { + it('returns an array', () => { + expect(editorOptions).toEqual(jasmine.any(Array)); + }); + + it('contains readOnly option', () => { + expect(editorOptions[0].readOnly).toBeDefined(); + }); +}); diff --git a/spec/javascripts/ide/lib/editor_spec.js b/spec/javascripts/ide/lib/editor_spec.js new file mode 100644 index 00000000000..d6df35c90e8 --- /dev/null +++ b/spec/javascripts/ide/lib/editor_spec.js @@ -0,0 +1,197 @@ +/* global monaco */ +import monacoLoader from '~/ide/monaco_loader'; +import editor from '~/ide/lib/editor'; +import { file } from '../helpers'; + +describe('Multi-file editor library', () => { + let instance; + let el; + let holder; + + beforeEach(done => { + el = document.createElement('div'); + holder = document.createElement('div'); + el.appendChild(holder); + + document.body.appendChild(el); + + monacoLoader(['vs/editor/editor.main'], () => { + instance = editor.create(monaco); + + done(); + }); + }); + + afterEach(() => { + instance.dispose(); + + el.remove(); + }); + + it('creates instance of editor', () => { + expect(editor.editorInstance).not.toBeNull(); + }); + + it('creates instance returns cached instance', () => { + expect(editor.create(monaco)).toEqual(instance); + }); + + describe('createInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'create').and.callThrough(); + + instance.createInstance(holder); + + expect(instance.monaco.editor.create).toHaveBeenCalled(); + }); + + it('creates dirty diff controller', () => { + instance.createInstance(holder); + + expect(instance.dirtyDiffController).not.toBeNull(); + }); + + it('creates model manager', () => { + instance.createInstance(holder); + + expect(instance.modelManager).not.toBeNull(); + }); + }); + + describe('createDiffInstance', () => { + it('creates editor instance', () => { + spyOn(instance.monaco.editor, 'createDiffEditor').and.callThrough(); + + instance.createDiffInstance(holder); + + expect(instance.monaco.editor.createDiffEditor).toHaveBeenCalledWith( + holder, + { + model: null, + contextmenu: true, + minimap: { + enabled: false, + }, + readOnly: true, + scrollBeyondLastLine: false, + }, + ); + }); + }); + + describe('createModel', () => { + it('calls model manager addModel', () => { + spyOn(instance.modelManager, 'addModel'); + + instance.createModel('FILE'); + + expect(instance.modelManager.addModel).toHaveBeenCalledWith('FILE'); + }); + }); + + describe('attachModel', () => { + let model; + + beforeEach(() => { + instance.createInstance(document.createElement('div')); + + model = instance.createModel(file()); + }); + + it('sets the current model on the instance', () => { + instance.attachModel(model); + + expect(instance.currentModel).toBe(model); + }); + + it('attaches the model to the current instance', () => { + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith(model.getModel()); + }); + + it('sets original & modified when diff editor', () => { + spyOn(instance.instance, 'getEditorType').and.returnValue( + 'vs.editor.IDiffEditor', + ); + spyOn(instance.instance, 'setModel'); + + instance.attachModel(model); + + expect(instance.instance.setModel).toHaveBeenCalledWith({ + original: model.getOriginalModel(), + modified: model.getModel(), + }); + }); + + it('attaches the model to the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'attachModel'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.attachModel).toHaveBeenCalledWith( + model, + ); + }); + + it('re-decorates with the dirty diff controller', () => { + spyOn(instance.dirtyDiffController, 'reDecorate'); + + instance.attachModel(model); + + expect(instance.dirtyDiffController.reDecorate).toHaveBeenCalledWith( + model, + ); + }); + }); + + describe('clearEditor', () => { + it('resets the editor model', () => { + instance.createInstance(document.createElement('div')); + + spyOn(instance.instance, 'setModel'); + + instance.clearEditor(); + + expect(instance.instance.setModel).toHaveBeenCalledWith(null); + }); + }); + + describe('dispose', () => { + it('calls disposble dispose method', () => { + spyOn(instance.disposable, 'dispose').and.callThrough(); + + instance.dispose(); + + expect(instance.disposable.dispose).toHaveBeenCalled(); + }); + + it('resets instance', () => { + instance.createInstance(document.createElement('div')); + + expect(instance.instance).not.toBeNull(); + + instance.dispose(); + + expect(instance.instance).toBeNull(); + }); + + it('does not dispose modelManager', () => { + spyOn(instance.modelManager, 'dispose'); + + instance.dispose(); + + expect(instance.modelManager.dispose).not.toHaveBeenCalled(); + }); + + it('does not dispose decorationsController', () => { + spyOn(instance.decorationsController, 'dispose'); + + instance.dispose(); + + expect(instance.decorationsController.dispose).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/javascripts/ide/monaco_loader_spec.js b/spec/javascripts/ide/monaco_loader_spec.js new file mode 100644 index 00000000000..7ab315aa8c8 --- /dev/null +++ b/spec/javascripts/ide/monaco_loader_spec.js @@ -0,0 +1,15 @@ +import monacoContext from 'monaco-editor/dev/vs/loader'; +import monacoLoader from '~/ide/monaco_loader'; + +describe('MonacoLoader', () => { + it('calls require.config and exports require', () => { + expect(monacoContext.require.getConfig()).toEqual( + jasmine.objectContaining({ + paths: { + vs: `${__webpack_public_path__}monaco-editor/vs`, // eslint-disable-line camelcase + }, + }), + ); + expect(monacoLoader).toBe(monacoContext.require); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js new file mode 100644 index 00000000000..5b7c8365641 --- /dev/null +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -0,0 +1,421 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; +import eventHub from '~/ide/eventhub'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store file actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('closeFile', () => { + let localFile; + + beforeEach(() => { + localFile = file('testFile'); + localFile.active = true; + localFile.opened = true; + localFile.parentTreeUrl = 'parentTreeUrl'; + + store.state.openFiles.push(localFile); + store.state.entries[localFile.path] = localFile; + }); + + it('closes open files', done => { + store + .dispatch('closeFile', localFile.path) + .then(() => { + expect(localFile.opened).toBeFalsy(); + expect(localFile.active).toBeFalsy(); + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes file even if file has changes', done => { + store.state.changedFiles.push(localFile); + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(store.state.openFiles.length).toBe(0); + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('closes file & opens next available file', done => { + const f = { + ...file('newOpenFile'), + url: '/newOpenFile', + }; + + store.state.openFiles.push(f); + store.state.entries[f.path] = f; + + store + .dispatch('closeFile', localFile.path) + .then(Vue.nextTick) + .then(() => { + expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setFileActive', () => { + let localFile; + let scrollToTabSpy; + let oldScrollToTab; + + beforeEach(() => { + scrollToTabSpy = jasmine.createSpy('scrollToTab'); + oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line + store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line + + localFile = file('setThisActive'); + + store.state.entries[localFile.path] = localFile; + }); + + afterEach(() => { + store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line + }); + + it('calls scrollToTab', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file active', done => { + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns early if file is already active', done => { + localFile.active = true; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(scrollToTabSpy).not.toHaveBeenCalled(); + + done(); + }) + .catch(done.fail); + }); + + it('sets current active file to not active', done => { + const f = file('newActive'); + store.state.entries[f.path] = f; + localFile.active = true; + store.state.openFiles.push(localFile); + + store + .dispatch('setFileActive', f.path) + .then(() => { + expect(localFile.active).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('resets location.hash for line highlighting', done => { + location.hash = 'test'; + + store + .dispatch('setFileActive', localFile.path) + .then(() => { + expect(location.hash).not.toBe('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getFileData', () => { + let localFile; + + beforeEach(() => { + spyOn(service, 'getFileData').and.returnValue( + Promise.resolve({ + headers: { + 'page-title': 'testing getFileData', + }, + json: () => + Promise.resolve({ + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + raw_path: 'raw_path', + binary: false, + html: '123', + render_error: '', + }), + }), + ); + + localFile = file(`newCreate-${Math.random()}`); + localFile.url = 'getFileDataURL'; + store.state.entries[localFile.path] = localFile; + }); + + it('calls the service', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(service.getFileData).toHaveBeenCalledWith('getFileDataURL'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file data', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.blamePath).toBe('blame_path'); + + done(); + }) + .catch(done.fail); + }); + + it('sets document title', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(document.title).toBe('testing getFileData'); + + done(); + }) + .catch(done.fail); + }); + + it('sets the file as active', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(localFile.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('adds the file to open files', done => { + store + .dispatch('getFileData', localFile) + .then(() => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(localFile.name); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getRawFileData', () => { + let tmpFile; + + beforeEach(() => { + spyOn(service, 'getRawFileData').and.returnValue(Promise.resolve('raw')); + + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('calls getRawFileData service method', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(service.getRawFileData).toHaveBeenCalledWith(tmpFile); + + done(); + }) + .catch(done.fail); + }); + + it('updates file raw data', done => { + store + .dispatch('getRawFileData', tmpFile) + .then(() => { + expect(tmpFile.raw).toBe('raw'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('changeFileContent', () => { + let tmpFile; + + beforeEach(() => { + tmpFile = file('tmpFile'); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('updates file content', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(tmpFile.content).toBe('content'); + + done(); + }) + .catch(done.fail); + }); + + it('adds file into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('adds file once into changedFiles array', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content 123', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(1); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array if not changed', done => { + store + .dispatch('changeFileContent', { + path: tmpFile.path, + content: 'content', + }) + .then(() => + store.dispatch('changeFileContent', { + path: tmpFile.path, + content: '', + }), + ) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardFileChanges', () => { + let tmpFile; + + beforeEach(() => { + spyOn(eventHub, '$on'); + + tmpFile = file(); + tmpFile.content = 'testing'; + + store.state.changedFiles.push(tmpFile); + store.state.entries[tmpFile.path] = tmpFile; + }); + + it('resets file content', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.content).not.toBe('testing'); + + done(); + }) + .catch(done.fail); + }); + + it('removes file from changedFiles array', done => { + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + + it('closes temp file', done => { + tmpFile.tempFile = true; + tmpFile.opened = true; + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + + it('does not re-open a closed temp file', done => { + tmpFile.tempFile = true; + + expect(tmpFile.opened).toBeFalsy(); + + store + .dispatch('discardFileChanges', tmpFile.path) + .then(() => { + expect(tmpFile.opened).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions/tree_spec.js b/spec/javascripts/ide/stores/actions/tree_spec.js new file mode 100644 index 00000000000..381f038067b --- /dev/null +++ b/spec/javascripts/ide/stores/actions/tree_spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; +import { file, resetStore } from '../../helpers'; + +describe('Multi-file store tree actions', () => { + let projectTree; + + const basicCallParameters = { + endpoint: 'rootEndpoint', + projectId: 'abcproject', + branch: 'master', + branchId: 'master', + }; + + beforeEach(() => { + spyOn(router, 'push'); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: '', + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + afterEach(() => { + resetStore(store); + }); + + describe('getFiles', () => { + beforeEach(() => { + spyOn(service, 'getFiles').and.returnValue( + Promise.resolve({ + json: () => + Promise.resolve([ + 'file.txt', + 'folder/fileinfolder.js', + 'folder/subfolder/fileinsubfolder.js', + ]), + }), + ); + }); + + it('calls service getFiles', done => { + store + .dispatch('getFiles', basicCallParameters) + .then(() => { + expect(service.getFiles).toHaveBeenCalledWith('', 'master'); + + done(); + }) + .catch(done.fail); + }); + + it('adds data into tree', done => { + store + .dispatch('getFiles', basicCallParameters) + .then(() => { + projectTree = store.state.trees['abcproject/master']; + expect(projectTree.tree.length).toBe(2); + expect(projectTree.tree[0].type).toBe('tree'); + expect(projectTree.tree[0].tree[1].name).toBe('fileinfolder.js'); + expect(projectTree.tree[1].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].type).toBe('blob'); + expect(projectTree.tree[0].tree[0].tree[0].name).toBe( + 'fileinsubfolder.js', + ); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('toggleTreeOpen', () => { + let tree; + + beforeEach(() => { + tree = file('testing', '1', 'tree'); + store.state.entries[tree.path] = tree; + }); + + it('toggles the tree open', done => { + store + .dispatch('toggleTreeOpen', tree.path) + .then(() => { + expect(tree.opened).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('getLastCommitData', () => { + beforeEach(() => { + spyOn(service, 'getTreeLastCommit').and.returnValue( + Promise.resolve({ + headers: { + 'more-logs-url': null, + }, + json: () => + Promise.resolve([ + { + type: 'tree', + file_name: 'testing', + commit: { + message: 'commit message', + authored_date: '123', + }, + }, + ]), + }), + ); + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + + projectTree = store.state.trees['abcproject/mybranch']; + projectTree.tree.push(file('testing', '1', 'tree')); + projectTree.lastCommitPath = 'lastcommitpath'; + }); + + it('calls service with lastCommitPath', done => { + store + .dispatch('getLastCommitData', projectTree) + .then(() => { + expect(service.getTreeLastCommit).toHaveBeenCalledWith( + 'lastcommitpath', + ); + + done(); + }) + .catch(done.fail); + }); + + it('updates trees last commit data', done => { + store + .dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).toBe('commit message'); + + done(); + }) + .catch(done.fail); + }); + + it('does not update entry if not found', done => { + projectTree.tree[0].name = 'a'; + + store + .dispatch('getLastCommitData', projectTree) + .then(Vue.nextTick) + .then(() => { + expect(projectTree.tree[0].lastCommit.message).not.toBe( + 'commit message', + ); + + done(); + }) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js new file mode 100644 index 00000000000..cec572f4507 --- /dev/null +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -0,0 +1,306 @@ +import * as urlUtils from '~/lib/utils/url_utility'; +import store from '~/ide/stores'; +import router from '~/ide/ide_router'; +import { resetStore, file } from '../helpers'; + +describe('Multi-file store actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('redirectToUrl', () => { + it('calls visitUrl', done => { + spyOn(urlUtils, 'visitUrl'); + + store + .dispatch('redirectToUrl', 'test') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith('test'); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('setInitialData', () => { + it('commits initial data', done => { + store + .dispatch('setInitialData', { canCommit: true }) + .then(() => { + expect(store.state.canCommit).toBeTruthy(); + done(); + }) + .catch(done.fail); + }); + }); + + describe('discardAllChanges', () => { + beforeEach(() => { + const f = file('discardAll'); + f.changed = true; + + store.state.openFiles.push(f); + store.state.changedFiles.push(f); + store.state.entries[f.path] = f; + }); + + it('discards changes in file', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.openFiles.changed).toBeFalsy(); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all files from changedFiles state', done => { + store + .dispatch('discardAllChanges') + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + expect(store.state.openFiles.length).toBe(1); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('closeAllFiles', () => { + beforeEach(() => { + const f = file('closeAll'); + store.state.openFiles.push(f); + store.state.openFiles[0].opened = true; + store.state.entries[f.path] = f; + }); + + it('closes all open files', done => { + store + .dispatch('closeAllFiles') + .then(() => { + expect(store.state.openFiles.length).toBe(0); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('createTempEntry', () => { + beforeEach(() => { + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'mybranch'; + + store.state.trees['abcproject/mybranch'] = { + tree: [], + }; + store.state.projects.abcproject = { + web_url: '', + }; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('tree', () => { + it('creates temp tree', done => { + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'test', + type: 'tree', + }) + .then(() => { + const entry = store.state.entries.test; + + expect(entry).not.toBeNull(); + expect(entry.type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('creates new folder inside another tree', done => { + const tree = { + type: 'tree', + name: 'testing', + path: 'testing', + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing/test', + type: 'tree', + }) + .then(() => { + expect(tree.tree[0].tempFile).toBeTruthy(); + expect(tree.tree[0].name).toBe('test'); + expect(tree.tree[0].type).toBe('tree'); + + done(); + }) + .catch(done.fail); + }); + + it('does not create new tree if already exists', done => { + const tree = { + type: 'tree', + path: 'testing', + tempFile: false, + tree: [], + }; + + store.state.entries[tree.path] = tree; + + store + .dispatch('createTempEntry', { + branchId: store.state.currentBranchId, + name: 'testing', + type: 'tree', + }) + .then(() => { + expect(store.state.entries[tree.path].tempFile).toEqual(false); + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('blob', () => { + it('creates temp file', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.tempFile).toBeTruthy(); + expect(store.state.trees['abcproject/mybranch'].tree.length).toBe( + 1, + ); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to open files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.openFiles.length).toBe(1); + expect(store.state.openFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('adds tmp file to changed files', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(store.state.changedFiles.length).toBe(1); + expect(store.state.changedFiles[0].name).toBe(f.name); + + done(); + }) + .catch(done.fail); + }); + + it('sets tmp file as active', done => { + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(f => { + expect(f.active).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('creates flash message if file already exists', done => { + const f = file('test', '1', 'blob'); + store.state.trees['abcproject/mybranch'].tree = [f]; + store.state.entries[f.path] = f; + + store + .dispatch('createTempEntry', { + name: 'test', + branchId: 'mybranch', + type: 'blob', + }) + .then(() => { + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + done(); + }) + .catch(done.fail); + }); + }); + }); + + describe('popHistoryState', () => {}); + + describe('scrollToTab', () => { + it('focuses the current active element', done => { + document.body.innerHTML += + '<div id="tabs"><div class="active"><div class="repo-tab"></div></div></div>'; + const el = document.querySelector('.repo-tab'); + spyOn(el, 'focus'); + + store + .dispatch('scrollToTab') + .then(() => { + setTimeout(() => { + expect(el.focus).toHaveBeenCalled(); + + document.getElementById('tabs').remove(); + + done(); + }); + }) + .catch(done.fail); + }); + }); + + describe('updateViewer', () => { + it('updates viewer state', done => { + store + .dispatch('updateViewer', 'diff') + .then(() => { + expect(store.state.viewer).toBe('diff'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/getters_spec.js b/spec/javascripts/ide/stores/getters_spec.js new file mode 100644 index 00000000000..a613f3a21cc --- /dev/null +++ b/spec/javascripts/ide/stores/getters_spec.js @@ -0,0 +1,55 @@ +import * as getters from '~/ide/stores/getters'; +import state from '~/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store getters', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('activeFile', () => { + it('returns the current active file', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + localState.openFiles[1].active = true; + + expect(getters.activeFile(localState).name).toBe('active'); + }); + + it('returns undefined if no active files are found', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('active')); + + expect(getters.activeFile(localState)).toBeNull(); + }); + }); + + describe('modifiedFiles', () => { + it('returns a list of modified files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('changed')); + localState.changedFiles[0].changed = true; + + const modifiedFiles = getters.modifiedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('changed'); + }); + }); + + describe('addedFiles', () => { + it('returns a list of added files', () => { + localState.openFiles.push(file()); + localState.changedFiles.push(file('added')); + localState.changedFiles[0].changed = true; + localState.changedFiles[0].tempFile = true; + + const modifiedFiles = getters.addedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('added'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js new file mode 100644 index 00000000000..90ded940227 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -0,0 +1,505 @@ +import store from '~/ide/stores'; +import service from '~/ide/services'; +import router from '~/ide/ide_router'; +import * as urlUtils from '~/lib/utils/url_utility'; +import eventHub from '~/ide/eventhub'; +import * as consts from '~/ide/stores/modules/commit/constants'; +import { resetStore, file } from 'spec/ide/helpers'; + +describe('IDE commit module actions', () => { + beforeEach(() => { + spyOn(router, 'push'); + }); + + afterEach(() => { + resetStore(store); + }); + + describe('updateCommitMessage', () => { + it('updates store with new commit message', done => { + store + .dispatch('commit/updateCommitMessage', 'testing') + .then(() => { + expect(store.state.commit.commitMessage).toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('discardDraft', () => { + it('resets commit message to blank', done => { + store.state.commit.commitMessage = 'testing'; + + store + .dispatch('commit/discardDraft') + .then(() => { + expect(store.state.commit.commitMessage).not.toBe('testing'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateCommitAction', () => { + it('updates store with new commit action', done => { + store + .dispatch('commit/updateCommitAction', '1') + .then(() => { + expect(store.state.commit.commitAction).toBe('1'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('updateBranchName', () => { + it('updates store with new branch name', done => { + store + .dispatch('commit/updateBranchName', 'branch-name') + .then(() => { + expect(store.state.commit.newBranchName).toBe('branch-name'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('setLastCommitMessage', () => { + beforeEach(() => { + Object.assign(store.state, { + currentProjectId: 'abcproject', + projects: { + abcproject: { + web_url: 'http://testing', + }, + }, + }); + }); + + it('updates commit message with short_id', done => { + store + .dispatch('commit/setLastCommitMessage', { short_id: '123' }) + .then(() => { + expect(store.state.lastCommitMsg).toContain( + 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a>', + ); + }) + .then(done) + .catch(done.fail); + }); + + it('updates commit message with stats', done => { + store + .dispatch('commit/setLastCommitMessage', { + short_id: '123', + stats: { + additions: '1', + deletions: '2', + }, + }) + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="http://testing/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('checkCommitStatus', () => { + beforeEach(() => { + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + branches: { + master: { + workingReference: '1', + }, + }, + }; + }); + + it('calls service', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '123' }, + }, + }), + ); + + store + .dispatch('commit/checkCommitStatus') + .then(() => { + expect(service.getBranchData).toHaveBeenCalledWith( + 'abcproject', + 'master', + ); + + done(); + }) + .catch(done.fail); + }); + + it('returns true if current ref does not equal returned ID', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '123' }, + }, + }), + ); + + store + .dispatch('commit/checkCommitStatus') + .then(val => { + expect(val).toBeTruthy(); + + done(); + }) + .catch(done.fail); + }); + + it('returns false if current ref equals returned ID', done => { + spyOn(service, 'getBranchData').and.returnValue( + Promise.resolve({ + data: { + commit: { id: '1' }, + }, + }), + ); + + store + .dispatch('commit/checkCommitStatus') + .then(val => { + expect(val).toBeFalsy(); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('updateFilesAfterCommit', () => { + const data = { + id: '123', + message: 'testing commit message', + committed_date: '123', + committer_name: 'root', + }; + const branch = 'master'; + let f; + + beforeEach(() => { + spyOn(eventHub, '$emit'); + + f = file('changedFile'); + Object.assign(f, { + active: true, + changed: true, + content: 'file content', + }); + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'web_url', + branches: { + master: { + workingReference: '', + }, + }, + }; + store.state.changedFiles.push(f, { + ...file('changedFile2'), + changed: true, + }); + store.state.openFiles = store.state.changedFiles; + + store.state.changedFiles.forEach(changedFile => { + store.state.entries[changedFile.path] = changedFile; + }); + }); + + it('updates stores working reference', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect( + store.state.projects.abcproject.branches.master.workingReference, + ).toBe(data.id); + }) + .then(done) + .catch(done.fail); + }); + + it('resets all files changed status', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + store.state.openFiles.forEach(entry => { + expect(entry.changed).toBeFalsy(); + }); + }) + .then(done) + .catch(done.fail); + }); + + it('removes all changed files', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.changedFiles.length).toBe(0); + }) + .then(done) + .catch(done.fail); + }); + + it('sets files commit data', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.lastCommit.message).toBe(data.message); + }) + .then(done) + .catch(done.fail); + }); + + it('updates raw content for changed file', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(f.raw).toBe(f.content); + }) + .then(done) + .catch(done.fail); + }); + + it('emits changed event for file', done => { + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith( + `editor.update.model.content.${f.path}`, + f.content, + ); + }) + .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); + }); + + it('resets stores commit actions', done => { + store.state.commit.commitAction = consts.COMMIT_TO_NEW_BRANCH; + + store + .dispatch('commit/updateFilesAfterCommit', { + data, + branch, + }) + .then(() => { + expect(store.state.commit.commitAction).not.toBe( + consts.COMMIT_TO_NEW_BRANCH, + ); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('commitChanges', () => { + beforeEach(() => { + spyOn(urlUtils, 'visitUrl'); + + document.body.innerHTML += '<div class="flash-container"></div>'; + + store.state.currentProjectId = 'abcproject'; + store.state.currentBranchId = 'master'; + store.state.projects.abcproject = { + web_url: 'webUrl', + branches: { + master: { + workingReference: '1', + }, + }, + }; + store.state.changedFiles.push(file('changed')); + store.state.changedFiles[0].active = true; + store.state.openFiles = store.state.changedFiles; + + store.state.openFiles.forEach(f => { + store.state.entries[f.path] = f; + }); + + store.state.commit.commitAction = '2'; + store.state.commit.commitMessage = 'testing 123'; + }); + + afterEach(() => { + document.querySelector('.flash-container').remove(); + }); + + describe('success', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue( + Promise.resolve({ + data: { + id: '123456', + short_id: '123', + message: 'test message', + committed_date: 'date', + stats: { + additions: '1', + deletions: '2', + }, + }, + }), + ); + }); + + it('calls service', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(service.commit).toHaveBeenCalledWith('abcproject', { + branch: jasmine.anything(), + commit_message: 'testing 123', + actions: [ + { + action: 'update', + file_path: jasmine.anything(), + content: jasmine.anything(), + encoding: jasmine.anything(), + }, + ], + start_branch: 'master', + }); + + done(); + }) + .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') + .then(() => { + expect(store.state.lastCommitMsg).toBe( + 'Your changes have been committed. Commit <a href="webUrl/commit/123" class="commit-sha">123</a> with 1 additions, 2 deletions.', + ); + + done(); + }) + .catch(done.fail); + }); + + it('adds commit data to changed files', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + expect(store.state.openFiles[0].lastCommit.message).toBe( + 'test message', + ); + + done(); + }) + .catch(done.fail); + }); + + it('redirects to new merge request page', done => { + spyOn(eventHub, '$on'); + + store.state.commit.commitAction = '3'; + + store + .dispatch('commit/commitChanges') + .then(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith( + `webUrl/merge_requests/new?merge_request[source_branch]=${ + store.getters['commit/newBranchName'] + }&merge_request[target_branch]=master`, + ); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('failed', () => { + beforeEach(() => { + spyOn(service, 'commit').and.returnValue( + Promise.resolve({ + data: { + message: 'failed message', + }, + }), + ); + }); + + it('shows failed message', done => { + store + .dispatch('commit/commitChanges') + .then(() => { + const alert = document.querySelector('.flash-container'); + + expect(alert.textContent.trim()).toBe('failed message'); + + done(); + }) + .catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/getters_spec.js b/spec/javascripts/ide/stores/modules/commit/getters_spec.js new file mode 100644 index 00000000000..e396284ec2c --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/getters_spec.js @@ -0,0 +1,128 @@ +import commitState from '~/ide/stores/modules/commit/state'; +import * as consts from '~/ide/stores/modules/commit/constants'; +import * as getters from '~/ide/stores/modules/commit/getters'; + +describe('IDE commit module getters', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('discardDraftButtonDisabled', () => { + it('returns true when commitMessage is empty', () => { + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + + it('returns false when commitMessage is not empty & loading is false', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = false; + + expect(getters.discardDraftButtonDisabled(state)).toBeFalsy(); + }); + + it('returns true when commitMessage is not empty & loading is true', () => { + state.commitMessage = 'test'; + state.submitCommitLoading = true; + + expect(getters.discardDraftButtonDisabled(state)).toBeTruthy(); + }); + }); + + describe('commitButtonDisabled', () => { + const localGetters = { + discardDraftButtonDisabled: false, + }; + const rootState = { + changedFiles: ['a'], + }; + + it('returns false when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeFalsy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is empty', () => { + rootState.changedFiles.length = 0; + + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is true', () => { + localGetters.discardDraftButtonDisabled = true; + + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); + }); + + it('returns true when discardDraftButtonDisabled is false & changedFiles is not empty', () => { + localGetters.discardDraftButtonDisabled = false; + rootState.changedFiles.length = 0; + + expect( + getters.commitButtonDisabled(state, localGetters, rootState), + ).toBeTruthy(); + }); + }); + + describe('newBranchName', () => { + it('includes username, currentBranchId, patch & random number', () => { + gon.current_username = 'username'; + + const branch = getters.newBranchName(state, null, { + currentBranchId: 'testing', + }); + + expect(branch).toMatch(/username-testing-patch-\d{5}$/); + }); + }); + + describe('branchName', () => { + const rootState = { + currentBranchId: 'master', + }; + const localGetters = { + newBranchName: 'newBranchName', + }; + + beforeEach(() => { + Object.assign(state, { + newBranchName: 'state-newBranchName', + }); + }); + + it('defualts to currentBranchId', () => { + expect(getters.branchName(state, null, rootState)).toBe('master'); + }); + + ['COMMIT_TO_NEW_BRANCH', 'COMMIT_TO_NEW_BRANCH_MR'].forEach(type => { + describe(type, () => { + beforeEach(() => { + Object.assign(state, { + commitAction: consts[type], + }); + }); + + it('uses newBranchName when not empty', () => { + expect(getters.branchName(state, localGetters, rootState)).toBe( + 'state-newBranchName', + ); + }); + + it('uses getters newBranchName when state newBranchName is empty', () => { + Object.assign(state, { + newBranchName: '', + }); + + expect(getters.branchName(state, localGetters, rootState)).toBe( + 'newBranchName', + ); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/modules/commit/mutations_spec.js b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js new file mode 100644 index 00000000000..5de7a281d34 --- /dev/null +++ b/spec/javascripts/ide/stores/modules/commit/mutations_spec.js @@ -0,0 +1,42 @@ +import commitState from '~/ide/stores/modules/commit/state'; +import mutations from '~/ide/stores/modules/commit/mutations'; + +describe('IDE commit module mutations', () => { + let state; + + beforeEach(() => { + state = commitState(); + }); + + describe('UPDATE_COMMIT_MESSAGE', () => { + it('updates commitMessage', () => { + mutations.UPDATE_COMMIT_MESSAGE(state, 'testing'); + + expect(state.commitMessage).toBe('testing'); + }); + }); + + describe('UPDATE_COMMIT_ACTION', () => { + it('updates commitAction', () => { + mutations.UPDATE_COMMIT_ACTION(state, 'testing'); + + expect(state.commitAction).toBe('testing'); + }); + }); + + describe('UPDATE_NEW_BRANCH_NAME', () => { + it('updates newBranchName', () => { + mutations.UPDATE_NEW_BRANCH_NAME(state, 'testing'); + + expect(state.newBranchName).toBe('testing'); + }); + }); + + describe('UPDATE_LOADING', () => { + it('updates submitCommitLoading', () => { + mutations.UPDATE_LOADING(state, true); + + expect(state.submitCommitLoading).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/branch_spec.js b/spec/javascripts/ide/stores/mutations/branch_spec.js new file mode 100644 index 00000000000..a7167537ef2 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/branch_spec.js @@ -0,0 +1,18 @@ +import mutations from '~/ide/stores/mutations/branch'; +import state from '~/ide/stores/state'; + +describe('Multi-file store branch mutations', () => { + let localState; + + beforeEach(() => { + localState = state(); + }); + + describe('SET_CURRENT_BRANCH', () => { + it('sets currentBranch', () => { + mutations.SET_CURRENT_BRANCH(localState, 'master'); + + expect(localState.currentBranchId).toBe('master'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js new file mode 100644 index 00000000000..131380248e8 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -0,0 +1,157 @@ +import mutations from '~/ide/stores/mutations/file'; +import state from '~/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store file mutations', () => { + let localState; + let localFile; + + beforeEach(() => { + localState = state(); + localFile = file(); + + localState.entries[localFile.path] = localFile; + }); + + describe('SET_FILE_ACTIVE', () => { + it('sets the file active', () => { + mutations.SET_FILE_ACTIVE(localState, { + path: localFile.path, + active: true, + }); + + expect(localFile.active).toBeTruthy(); + }); + }); + + describe('TOGGLE_FILE_OPEN', () => { + beforeEach(() => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + }); + + it('adds into opened files', () => { + expect(localFile.opened).toBeTruthy(); + expect(localState.openFiles.length).toBe(1); + }); + + it('removes from opened files', () => { + mutations.TOGGLE_FILE_OPEN(localState, localFile.path); + + expect(localFile.opened).toBeFalsy(); + expect(localState.openFiles.length).toBe(0); + }); + }); + + describe('SET_FILE_DATA', () => { + it('sets extra file data', () => { + mutations.SET_FILE_DATA(localState, { + data: { + blame_path: 'blame', + commits_path: 'commits', + permalink: 'permalink', + raw_path: 'raw', + binary: true, + render_error: 'render_error', + }, + file: localFile, + }); + + expect(localFile.blamePath).toBe('blame'); + expect(localFile.commitsPath).toBe('commits'); + expect(localFile.permalink).toBe('permalink'); + expect(localFile.rawPath).toBe('raw'); + expect(localFile.binary).toBeTruthy(); + expect(localFile.renderError).toBe('render_error'); + }); + }); + + describe('SET_FILE_RAW_DATA', () => { + it('sets raw data', () => { + mutations.SET_FILE_RAW_DATA(localState, { + file: localFile, + raw: 'testing', + }); + + expect(localFile.raw).toBe('testing'); + }); + }); + + describe('UPDATE_FILE_CONTENT', () => { + beforeEach(() => { + localFile.raw = 'test'; + }); + + it('sets content', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'test', + }); + + expect(localFile.content).toBe('test'); + }); + + it('sets changed if content does not match raw', () => { + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: 'testing', + }); + + expect(localFile.content).toBe('testing'); + expect(localFile.changed).toBeTruthy(); + }); + + it('sets changed if file is a temp file', () => { + localFile.tempFile = true; + + mutations.UPDATE_FILE_CONTENT(localState, { + path: localFile.path, + content: '', + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); + + describe('DISCARD_FILE_CHANGES', () => { + beforeEach(() => { + localFile.content = 'test'; + localFile.changed = true; + }); + + it('resets content and changed', () => { + mutations.DISCARD_FILE_CHANGES(localState, localFile.path); + + expect(localFile.content).toBe(''); + expect(localFile.changed).toBeFalsy(); + }); + }); + + describe('ADD_FILE_TO_CHANGED', () => { + it('adds file into changed files array', () => { + mutations.ADD_FILE_TO_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(1); + }); + }); + + describe('REMOVE_FILE_FROM_CHANGED', () => { + it('removes files from changed files array', () => { + localState.changedFiles.push(localFile); + + mutations.REMOVE_FILE_FROM_CHANGED(localState, localFile.path); + + expect(localState.changedFiles.length).toBe(0); + }); + }); + + describe('TOGGLE_FILE_CHANGED', () => { + it('updates file changed status', () => { + mutations.TOGGLE_FILE_CHANGED(localState, { + file: localFile, + changed: true, + }); + + expect(localFile.changed).toBeTruthy(); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations/tree_spec.js b/spec/javascripts/ide/stores/mutations/tree_spec.js new file mode 100644 index 00000000000..e6c085eaff6 --- /dev/null +++ b/spec/javascripts/ide/stores/mutations/tree_spec.js @@ -0,0 +1,69 @@ +import mutations from '~/ide/stores/mutations/tree'; +import state from '~/ide/stores/state'; +import { file } from '../../helpers'; + +describe('Multi-file store tree mutations', () => { + let localState; + let localTree; + + beforeEach(() => { + localState = state(); + localTree = file(); + + localState.entries[localTree.path] = localTree; + }); + + describe('TOGGLE_TREE_OPEN', () => { + it('toggles tree open', () => { + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeTruthy(); + + mutations.TOGGLE_TREE_OPEN(localState, localTree.path); + + expect(localTree.opened).toBeFalsy(); + }); + }); + + describe('SET_DIRECTORY_DATA', () => { + const data = [ + { + name: 'tree', + }, + { + name: 'submodule', + }, + { + name: 'blob', + }, + ]; + + it('adds directory data', () => { + localState.trees['project/master'] = { + tree: [], + }; + + mutations.SET_DIRECTORY_DATA(localState, { + data, + treePath: 'project/master', + }); + + const tree = localState.trees['project/master']; + + expect(tree.tree.length).toBe(3); + expect(tree.tree[0].name).toBe('tree'); + expect(tree.tree[1].name).toBe('submodule'); + expect(tree.tree[2].name).toBe('blob'); + }); + }); + + describe('REMOVE_ALL_CHANGES_FILES', () => { + it('removes all files from changedFiles state', () => { + localState.changedFiles.push(file('REMOVE_ALL_CHANGES_FILES')); + + mutations.REMOVE_ALL_CHANGES_FILES(localState); + + expect(localState.changedFiles.length).toBe(0); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js new file mode 100644 index 00000000000..38162a470ad --- /dev/null +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -0,0 +1,79 @@ +import mutations from '~/ide/stores/mutations'; +import state from '~/ide/stores/state'; +import { file } from '../helpers'; + +describe('Multi-file store mutations', () => { + let localState; + let entry; + + beforeEach(() => { + localState = state(); + entry = file(); + + localState.entries[entry.path] = entry; + }); + + describe('SET_INITIAL_DATA', () => { + it('sets all initial data', () => { + mutations.SET_INITIAL_DATA(localState, { + test: 'test', + }); + + expect(localState.test).toBe('test'); + }); + }); + + describe('TOGGLE_LOADING', () => { + it('toggles loading of entry', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeFalsy(); + }); + + it('toggles loading of entry and sets specific value', () => { + mutations.TOGGLE_LOADING(localState, { entry }); + + expect(entry.loading).toBeTruthy(); + + mutations.TOGGLE_LOADING(localState, { entry, forceValue: true }); + + expect(entry.loading).toBeTruthy(); + }); + }); + + describe('SET_LEFT_PANEL_COLLAPSED', () => { + it('sets left panel collapsed', () => { + mutations.SET_LEFT_PANEL_COLLAPSED(localState, true); + + expect(localState.leftPanelCollapsed).toBeTruthy(); + + mutations.SET_LEFT_PANEL_COLLAPSED(localState, false); + + expect(localState.leftPanelCollapsed).toBeFalsy(); + }); + }); + + describe('SET_RIGHT_PANEL_COLLAPSED', () => { + it('sets right panel collapsed', () => { + mutations.SET_RIGHT_PANEL_COLLAPSED(localState, true); + + expect(localState.rightPanelCollapsed).toBeTruthy(); + + mutations.SET_RIGHT_PANEL_COLLAPSED(localState, false); + + expect(localState.rightPanelCollapsed).toBeFalsy(); + }); + }); + + describe('UPDATE_VIEWER', () => { + it('sets viewer state', () => { + mutations.UPDATE_VIEWER(localState, 'diff'); + + expect(localState.viewer).toBe('diff'); + }); + }); +}); diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js new file mode 100644 index 00000000000..f38ac6dd82f --- /dev/null +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -0,0 +1,66 @@ +import * as utils from '~/ide/stores/utils'; + +describe('Multi-file store utils', () => { + describe('setPageTitle', () => { + it('sets the document page title', () => { + utils.setPageTitle('test'); + + expect(document.title).toBe('test'); + }); + }); + + describe('findIndexOfFile', () => { + let localState; + + beforeEach(() => { + localState = [ + { + path: '1', + }, + { + path: '2', + }, + ]; + }); + + it('finds in the index of an entry by path', () => { + const index = utils.findIndexOfFile(localState, { + path: '2', + }); + + expect(index).toBe(1); + }); + }); + + describe('findEntry', () => { + let localState; + + beforeEach(() => { + localState = { + tree: [ + { + type: 'tree', + name: 'test', + }, + { + type: 'blob', + name: 'file', + }, + ], + }; + }); + + it('returns an entry found by name', () => { + const foundEntry = utils.findEntry(localState.tree, 'tree', 'test'); + + expect(foundEntry.type).toBe('tree'); + expect(foundEntry.name).toBe('test'); + }); + + it('returns undefined when no entry found', () => { + const foundEntry = utils.findEntry(localState.tree, 'blob', 'test'); + + expect(foundEntry).toBeUndefined(); + }); + }); +}); diff --git a/spec/javascripts/integrations/integration_settings_form_spec.js b/spec/javascripts/integrations/integration_settings_form_spec.js index d0fba908e34..050b1f2074e 100644 --- a/spec/javascripts/integrations/integration_settings_form_spec.js +++ b/spec/javascripts/integrations/integration_settings_form_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import MockAdaptor from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import IntegrationSettingsForm from '~/integrations/integration_settings_form'; diff --git a/spec/javascripts/issuable_spec.js b/spec/javascripts/issuable_spec.js index d53ffecbd35..57bf746f080 100644 --- a/spec/javascripts/issuable_spec.js +++ b/spec/javascripts/issuable_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import MockAdaptor from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import IssuableIndex from '~/issuable_index'; diff --git a/spec/javascripts/issuable_time_tracker_spec.js b/spec/javascripts/issuable_time_tracker_spec.js index 365e9fe6a4b..ba9040524b1 100644 --- a/spec/javascripts/issuable_time_tracker_spec.js +++ b/spec/javascripts/issuable_time_tracker_spec.js @@ -1,5 +1,6 @@ /* eslint-disable no-unused-vars, space-before-function-paren, func-call-spacing, no-spaced-func, semi, max-len, quotes, space-infix-ops, padded-blocks */ +import $ from 'jquery'; import Vue from 'vue'; import timeTracker from '~/sidebar/components/time_tracking/time_tracker.vue'; diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 584db6c6632..d5a87b5ce20 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -1,8 +1,7 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import '~/render_math'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import * as urlUtils from '~/lib/utils/url_utility'; import issuableApp from '~/issue_show/components/app.vue'; import eventHub from '~/issue_show/event_hub'; diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js index ff7f99eec14..d96151a8a3a 100644 --- a/spec/javascripts/issue_show/components/description_spec.js +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import descriptionComponent from '~/issue_show/components/description.vue'; import * as taskList from '~/task_list'; diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index 177962ecf82..f37426a72d4 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,4 +1,6 @@ /* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ + +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import Issue from '~/issue'; diff --git a/spec/javascripts/job_spec.js b/spec/javascripts/job_spec.js index b4599688c6d..c6bbacf237a 100644 --- a/spec/javascripts/job_spec.js +++ b/spec/javascripts/job_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils'; diff --git a/spec/javascripts/labels_issue_sidebar_spec.js b/spec/javascripts/labels_issue_sidebar_spec.js index 7d992f62f64..5aafb6ad8f0 100644 --- a/spec/javascripts/labels_issue_sidebar_spec.js +++ b/spec/javascripts/labels_issue_sidebar_spec.js @@ -1,4 +1,6 @@ /* eslint-disable no-new */ + +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import IssuableContext from '~/issuable_context'; diff --git a/spec/javascripts/labels_select_spec.js b/spec/javascripts/labels_select_spec.js index b8f7b1dc855..a2b89c0aef5 100644 --- a/spec/javascripts/labels_select_spec.js +++ b/spec/javascripts/labels_select_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import LabelsSelect from '~/labels_select'; const mockUrl = '/foo/bar/url'; diff --git a/spec/javascripts/lib/utils/text_markdown_spec.js b/spec/javascripts/lib/utils/text_markdown_spec.js index a95a7e2a5be..ca0e7c395a0 100644 --- a/spec/javascripts/lib/utils/text_markdown_spec.js +++ b/spec/javascripts/lib/utils/text_markdown_spec.js @@ -1,4 +1,4 @@ -import textUtils from '~/lib/utils/text_markdown'; +import { insertMarkdownText } from '~/lib/utils/text_markdown'; describe('init markdown', () => { let textArea; @@ -21,7 +21,7 @@ describe('init markdown', () => { textArea.selectionStart = 0; textArea.selectionEnd = 0; - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); @@ -32,7 +32,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}\n* `); }); @@ -43,7 +43,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); @@ -54,7 +54,7 @@ describe('init markdown', () => { textArea.value = initialValue; textArea.setSelectionRange(initialValue.length, initialValue.length); - textUtils.insertText(textArea, textArea.value, '*', null, '', false); + insertMarkdownText(textArea, textArea.value, '*', null, '', false); expect(textArea.value).toEqual(`${initialValue}* `); }); diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js index 89f4b85541d..d2bdc9e160c 100644 --- a/spec/javascripts/line_highlighter_spec.js +++ b/spec/javascripts/line_highlighter_spec.js @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren, no-var, no-param-reassign, quotes, prefer-template, no-else-return, new-cap, dot-notation, no-return-assign, comma-dangle, no-new, one-var, one-var-declaration-per-line, jasmine/no-spec-dupes, no-underscore-dangle, max-len */ +import $ from 'jquery'; import LineHighlighter from '~/line_highlighter'; (function() { diff --git a/spec/javascripts/merge_request_notes_spec.js b/spec/javascripts/merge_request_notes_spec.js index 0d16b23302f..dc9dc4d4249 100644 --- a/spec/javascripts/merge_request_notes_spec.js +++ b/spec/javascripts/merge_request_notes_spec.js @@ -1,9 +1,9 @@ +import $ from 'jquery'; import _ from 'underscore'; import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; -import '~/render_gfm'; -import '~/render_math'; +import '~/behaviors/markdown/render_gfm'; import Notes from '~/notes'; const upArrowKeyCode = 38; diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js index bdfd16ac995..74ceff76d37 100644 --- a/spec/javascripts/merge_request_spec.js +++ b/spec/javascripts/merge_request_spec.js @@ -1,4 +1,6 @@ /* eslint-disable space-before-function-paren, no-return-assign */ + +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import MergeRequest from '~/merge_request'; @@ -27,7 +29,7 @@ import IssuablesHelper from '~/helpers/issuables_helper'; }); it('modifies the Markdown field', function() { - spyOn(jQuery, 'ajax').and.stub(); + spyOn($, 'ajax').and.stub(); const changeEvent = document.createEvent('HTMLEvents'); changeEvent.initEvent('change', true, true); $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); @@ -48,7 +50,7 @@ import IssuablesHelper from '~/helpers/issuables_helper'; describe('class constructor', () => { beforeEach(() => { - spyOn(jQuery, 'ajax').and.stub(); + spyOn($, 'ajax').and.stub(); }); it('calls .initCloseReopenReport', () => { diff --git a/spec/javascripts/merge_request_tabs_spec.js b/spec/javascripts/merge_request_tabs_spec.js index fda24db98b4..79c8cf0ba32 100644 --- a/spec/javascripts/merge_request_tabs_spec.js +++ b/spec/javascripts/merge_request_tabs_spec.js @@ -1,4 +1,6 @@ /* eslint-disable no-var, comma-dangle, object-shorthand */ + +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import * as urlUtils from '~/lib/utils/url_utility'; diff --git a/spec/javascripts/mini_pipeline_graph_dropdown_spec.js b/spec/javascripts/mini_pipeline_graph_dropdown_spec.js index 6fa6f44f953..009b3fd75b7 100644 --- a/spec/javascripts/mini_pipeline_graph_dropdown_spec.js +++ b/spec/javascripts/mini_pipeline_graph_dropdown_spec.js @@ -1,5 +1,6 @@ /* eslint-disable no-new */ +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index 29b355307ef..eba6dcf47c5 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -18,6 +18,7 @@ describe('Dashboard', () => { deploymentEndpoint: null, emptyGettingStartedSvgPath: '/path/to/getting-started.svg', emptyLoadingSvgPath: '/path/to/loading.svg', + emptyNoDataSvgPath: '/path/to/no-data.svg', emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', }; diff --git a/spec/javascripts/monitoring/dashboard_state_spec.js b/spec/javascripts/monitoring/dashboard_state_spec.js index df3198dd3e2..b4c5f4baa78 100644 --- a/spec/javascripts/monitoring/dashboard_state_spec.js +++ b/spec/javascripts/monitoring/dashboard_state_spec.js @@ -2,13 +2,22 @@ import Vue from 'vue'; import EmptyState from '~/monitoring/components/empty_state.vue'; import { statePaths } from './mock_data'; -const createComponent = (propsData) => { +function createComponent(props) { const Component = Vue.extend(EmptyState); return new Component({ - propsData, + propsData: { + ...props, + settingsPath: statePaths.settingsPath, + clustersPath: statePaths.clustersPath, + documentationPath: statePaths.documentationPath, + emptyGettingStartedSvgPath: '/path/to/getting-started.svg', + emptyLoadingSvgPath: '/path/to/loading.svg', + emptyNoDataSvgPath: '/path/to/no-data.svg', + emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', + }, }).$mount(); -}; +} function getTextFromNode(component, selector) { return component.$el.querySelector(selector).firstChild.nodeValue.trim(); @@ -19,11 +28,6 @@ describe('EmptyState', () => { it('currentState', () => { const component = createComponent({ selectedState: 'gettingStarted', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.currentState).toBe(component.states.gettingStarted); @@ -32,11 +36,6 @@ describe('EmptyState', () => { it('showButtonDescription returns a description with a link for the unableToConnect state', () => { const component = createComponent({ selectedState: 'unableToConnect', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.showButtonDescription).toEqual(true); @@ -45,11 +44,6 @@ describe('EmptyState', () => { it('showButtonDescription returns the description without a link for any other state', () => { const component = createComponent({ selectedState: 'loading', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.showButtonDescription).toEqual(false); @@ -59,12 +53,6 @@ describe('EmptyState', () => { it('should show the gettingStarted state', () => { const component = createComponent({ selectedState: 'gettingStarted', - settingsPath: statePaths.settingsPath, - clustersPath: statePaths.clustersPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); @@ -76,11 +64,6 @@ describe('EmptyState', () => { it('should show the loading state', () => { const component = createComponent({ selectedState: 'loading', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); @@ -92,11 +75,6 @@ describe('EmptyState', () => { it('should show the unableToConnect state', () => { const component = createComponent({ selectedState: 'unableToConnect', - settingsPath: statePaths.settingsPath, - documentationPath: statePaths.documentationPath, - emptyGettingStartedSvgPath: 'foo', - emptyLoadingSvgPath: 'foo', - emptyUnableToConnectSvgPath: 'foo', }); expect(component.$el.querySelector('svg')).toBeDefined(); diff --git a/spec/javascripts/namespace_select_spec.js b/spec/javascripts/namespace_select_spec.js index 9d7625ca269..3b2641f7646 100644 --- a/spec/javascripts/namespace_select_spec.js +++ b/spec/javascripts/namespace_select_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import NamespaceSelect from '~/namespace_select'; describe('NamespaceSelect', () => { diff --git a/spec/javascripts/new_branch_spec.js b/spec/javascripts/new_branch_spec.js index 50a5e4ff056..5e5d8f8f34f 100644 --- a/spec/javascripts/new_branch_spec.js +++ b/spec/javascripts/new_branch_spec.js @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren, one-var, no-var, one-var-declaration-per-line, no-return-assign, quotes, max-len */ +import $ from 'jquery'; import NewBranchForm from '~/new_branch_form'; (function() { diff --git a/spec/javascripts/notes/components/comment_form_spec.js b/spec/javascripts/notes/components/comment_form_spec.js index 6a7131528a3..224debbeff6 100644 --- a/spec/javascripts/notes/components/comment_form_spec.js +++ b/spec/javascripts/notes/components/comment_form_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import Autosize from 'autosize'; import store from '~/notes/stores'; @@ -199,6 +200,20 @@ describe('issue_comment_form component', () => { done(); }); }); + + describe('when clicking close/reopen button', () => { + it('should disable button and show a loading spinner', (done) => { + const toggleStateButton = vm.$el.querySelector('.js-action-button'); + + toggleStateButton.click(); + Vue.nextTick(() => { + expect(toggleStateButton.disabled).toEqual(true); + expect(toggleStateButton.querySelector('.js-loading-button-icon')).not.toBeNull(); + + done(); + }); + }); + }); }); describe('issue is confidential', () => { diff --git a/spec/javascripts/notes/components/diff_file_header_spec.js b/spec/javascripts/notes/components/diff_file_header_spec.js index aed30a087a6..ef6d513444a 100644 --- a/spec/javascripts/notes/components/diff_file_header_spec.js +++ b/spec/javascripts/notes/components/diff_file_header_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import DiffFileHeader from '~/notes/components/diff_file_header.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const discussionFixture = 'merge_requests/diff_discussion.json'; diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js index 7f1f4bf0bcd..f4ec7132dbd 100644 --- a/spec/javascripts/notes/components/diff_with_note_spec.js +++ b/spec/javascripts/notes/components/diff_with_note_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import DiffWithNote from '~/notes/components/diff_with_note.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const discussionFixture = 'merge_requests/diff_discussion.json'; const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js index e1c612f5100..0e792eee5e9 100644 --- a/spec/javascripts/notes/components/note_app_spec.js +++ b/spec/javascripts/notes/components/note_app_spec.js @@ -1,8 +1,9 @@ +import $ from 'jquery'; import _ from 'underscore'; import Vue from 'vue'; import notesApp from '~/notes/components/notes_app.vue'; import service from '~/notes/services/notes_service'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import * as mockData from '../mock_data'; const vueMatchers = { diff --git a/spec/javascripts/notes/components/noteable_note_spec.js b/spec/javascripts/notes/components/noteable_note_spec.js index 88a7ffb0b9c..cfd037633e9 100644 --- a/spec/javascripts/notes/components/noteable_note_spec.js +++ b/spec/javascripts/notes/components/noteable_note_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import _ from 'underscore'; import Vue from 'vue'; import store from '~/notes/stores'; diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index bf60cb12f52..5be13ed0dfe 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -1,7 +1,7 @@ /* eslint-disable */ export const notesDataMock = { discussionsPath: '/gitlab-org/gitlab-ce/issues/26/discussions.json', - lastFetchedAt: '1501862675', + lastFetchedAt: 1501862675, markdownDocsPath: '/help/user/markdown', newSessionPath: '/users/sign_in?redirect_to_referer=yes', notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes', diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js index ab80ed7bbfb..91249b2c79e 100644 --- a/spec/javascripts/notes/stores/actions_spec.js +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import _ from 'underscore'; +import { headersInterceptor } from 'spec/helpers/vue_resource_helper'; import * as actions from '~/notes/stores/actions'; import store from '~/notes/stores'; import testAction from '../../helpers/vuex_action_helper'; @@ -87,6 +88,7 @@ describe('Actions Notes Store', () => { store.dispatch('closeIssue', { notesData: { closeIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('closed'); + expect(store.state.isToggleStateButtonLoading).toEqual(false); done(); }) .catch(done.fail); @@ -98,6 +100,7 @@ describe('Actions Notes Store', () => { store.dispatch('reopenIssue', { notesData: { reopenIssuePath: '' } }) .then(() => { expect(store.state.noteableData.state).toEqual('reopened'); + expect(store.state.isToggleStateButtonLoading).toEqual(false); done(); }) .catch(done.fail); @@ -116,6 +119,20 @@ describe('Actions Notes Store', () => { }); }); + describe('toggleStateButtonLoading', () => { + it('should set loading as true', (done) => { + testAction(actions.toggleStateButtonLoading, true, {}, [ + { type: 'TOGGLE_STATE_BUTTON_LOADING', payload: true }, + ], done); + }); + + it('should set loading as false', (done) => { + testAction(actions.toggleStateButtonLoading, false, {}, [ + { type: 'TOGGLE_STATE_BUTTON_LOADING', payload: false }, + ], done); + }); + }); + describe('toggleIssueLocalState', () => { it('sets issue state as closed', (done) => { testAction(actions.toggleIssueLocalState, 'closed', {}, [ @@ -129,4 +146,68 @@ describe('Actions Notes Store', () => { ], done); }); }); + + describe('poll', () => { + beforeEach((done) => { + jasmine.clock().install(); + + spyOn(Vue.http, 'get').and.callThrough(); + + store.dispatch('setNotesData', notesDataMock) + .then(done) + .catch(done.fail); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + it('calls service with last fetched state', (done) => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify({ + notes: [], + last_fetched_at: '123456', + }), { + status: 200, + headers: { + 'poll-interval': '1000', + }, + })); + }; + + Vue.http.interceptors.push(interceptor); + Vue.http.interceptors.push(headersInterceptor); + + store.dispatch('poll') + .then(() => new Promise(resolve => requestAnimationFrame(resolve))) + .then(() => { + expect(Vue.http.get).toHaveBeenCalledWith(jasmine.anything(), { + url: jasmine.anything(), + method: 'get', + headers: { + 'X-Last-Fetched-At': undefined, + }, + }); + expect(store.state.lastFetchedAt).toBe('123456'); + + jasmine.clock().tick(1500); + }) + .then(() => new Promise((resolve) => { + requestAnimationFrame(resolve); + })) + .then(() => { + expect(Vue.http.get.calls.count()).toBe(2); + expect(Vue.http.get.calls.mostRecent().args[1].headers).toEqual({ + 'X-Last-Fetched-At': '123456', + }); + }) + .then(() => store.dispatch('stopPolling')) + .then(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js index e4baefc5bfc..98f101d6bc5 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -101,10 +101,21 @@ describe('Notes Store mutations', () => { const state = { notes: [], }; + const legacyNote = { + id: 2, + individual_note: true, + notes: [{ + note: '1', + }, { + note: '2', + }], + }; - mutations.SET_INITIAL_NOTES(state, [note]); + mutations.SET_INITIAL_NOTES(state, [note, legacyNote]); expect(state.notes[0].id).toEqual(note.id); - expect(state.notes.length).toEqual(1); + expect(state.notes[1].notes[0].note).toBe(legacyNote.notes[0].note); + expect(state.notes[2].notes[0].note).toBe(legacyNote.notes[1].note); + expect(state.notes.length).toEqual(3); }); }); @@ -217,4 +228,70 @@ describe('Notes Store mutations', () => { expect(state.notes[0].notes[0].note).toEqual('Foo'); }); }); + + describe('CLOSE_ISSUE', () => { + it('should set issue as closed', () => { + const state = { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.CLOSE_ISSUE(state); + expect(state.noteableData.state).toEqual('closed'); + }); + }); + + describe('REOPEN_ISSUE', () => { + it('should set issue as closed', () => { + const state = { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.REOPEN_ISSUE(state); + expect(state.noteableData.state).toEqual('reopened'); + }); + }); + + describe('TOGGLE_STATE_BUTTON_LOADING', () => { + it('should set isToggleStateButtonLoading as true', () => { + const state = { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: false, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.TOGGLE_STATE_BUTTON_LOADING(state, true); + expect(state.isToggleStateButtonLoading).toEqual(true); + }); + + it('should set isToggleStateButtonLoading as false', () => { + const state = { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + isToggleStateButtonLoading: true, + notesData: {}, + userData: {}, + noteableData: {}, + }; + + mutations.TOGGLE_STATE_BUTTON_LOADING(state, false); + expect(state.isToggleStateButtonLoading).toEqual(false); + }); + }); }); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index d4a148e6ab1..1858d6b6474 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -1,4 +1,5 @@ /* eslint-disable space-before-function-paren, no-unused-expressions, no-var, object-shorthand, comma-dangle, max-len */ +import $ from 'jquery'; import _ from 'underscore'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; @@ -6,7 +7,7 @@ import * as urlUtils from '~/lib/utils/url_utility'; import 'autosize'; import '~/gl_form'; import '~/lib/utils/text_utility'; -import '~/render_gfm'; +import '~/behaviors/markdown/render_gfm'; import Notes from '~/notes'; import timeoutPromise from './helpers/set_timeout_promise_helper'; @@ -548,6 +549,20 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; }); }); + it('should disable the submit button when comment button is clicked', (done) => { + expect($form.find('.js-comment-submit-button').is(':disabled')).toEqual(false); + + mockNotesPost(); + $('.js-comment-button').click(); + expect($form.find('.js-comment-submit-button').is(':disabled')).toEqual(true); + + setTimeout(() => { + expect($form.find('.js-comment-submit-button').is(':disabled')).toEqual(false); + + done(); + }); + }); + it('should show actual note element when new comment is done posting', (done) => { mockNotesPost(); diff --git a/spec/javascripts/oauth_remember_me_spec.js b/spec/javascripts/oauth_remember_me_spec.js index b24563f738b..8816fe6defb 100644 --- a/spec/javascripts/oauth_remember_me_spec.js +++ b/spec/javascripts/oauth_remember_me_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import OAuthRememberMe from '~/pages/sessions/new/oauth_remember_me'; describe('OAuthRememberMe', () => { diff --git a/spec/javascripts/pages/admin/abuse_reports/abuse_reports_spec.js b/spec/javascripts/pages/admin/abuse_reports/abuse_reports_spec.js index 349549b9e1f..b0dc6ccc3d4 100644 --- a/spec/javascripts/pages/admin/abuse_reports/abuse_reports_spec.js +++ b/spec/javascripts/pages/admin/abuse_reports/abuse_reports_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import '~/lib/utils/text_utility'; import AbuseReports from '~/pages/admin/abuse_reports/abuse_reports'; diff --git a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js index ba2e07f02f7..080158a8ee0 100644 --- a/spec/javascripts/pages/labels/components/promote_label_modal_spec.js +++ b/spec/javascripts/pages/labels/components/promote_label_modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue'; import eventHub from '~/pages/projects/labels/event_hub'; import axios from '~/lib/utils/axios_utils'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Promote label modal', () => { let vm; diff --git a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js index bf044fe8fb5..22956929e7b 100644 --- a/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/javascripts/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; import eventHub from '~/pages/milestones/shared/event_hub'; import axios from '~/lib/utils/axios_utils'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('Promote milestone modal', () => { let vm; diff --git a/spec/javascripts/performance_bar/components/detailed_metric_spec.js b/spec/javascripts/performance_bar/components/detailed_metric_spec.js new file mode 100644 index 00000000000..c4611dc7662 --- /dev/null +++ b/spec/javascripts/performance_bar/components/detailed_metric_spec.js @@ -0,0 +1,80 @@ +import Vue from 'vue'; +import detailedMetric from '~/performance_bar/components/detailed_metric.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('detailedMetric', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('when the current request has no details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(detailedMetric), { + currentRequest: {}, + metric: 'gitaly', + header: 'Gitaly calls', + details: 'details', + keys: ['feature', 'request'], + }); + }); + + it('does not render the element', () => { + expect(vm.$el.innerHTML).toEqual(undefined); + }); + }); + + describe('when the current request has details', () => { + const requestDetails = [ + { duration: '100', feature: 'find_commit', request: 'abcdef' }, + { duration: '23', feature: 'rebase_in_progress', request: '' }, + ]; + + beforeEach(() => { + vm = mountComponent(Vue.extend(detailedMetric), { + currentRequest: { + details: { + gitaly: { + duration: '123ms', + calls: '456', + details: requestDetails, + }, + }, + }, + metric: 'gitaly', + header: 'Gitaly calls', + details: 'details', + keys: ['feature', 'request'], + }); + }); + + it('diplays details', () => { + expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456'); + }); + + it('adds a modal with a table of the details', () => { + vm.$el + .querySelectorAll('.performance-bar-modal td strong') + .forEach((duration, index) => { + expect(duration.innerText).toContain(requestDetails[index].duration); + }); + + vm.$el + .querySelectorAll('.performance-bar-modal td:nth-child(2)') + .forEach((feature, index) => { + expect(feature.innerText).toContain(requestDetails[index].feature); + }); + + vm.$el + .querySelectorAll('.performance-bar-modal td:nth-child(3)') + .forEach((request, index) => { + expect(request.innerText).toContain(requestDetails[index].request); + }); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); +}); diff --git a/spec/javascripts/performance_bar/components/performance_bar_app_spec.js b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js new file mode 100644 index 00000000000..9ab9ab1c9f4 --- /dev/null +++ b/spec/javascripts/performance_bar/components/performance_bar_app_spec.js @@ -0,0 +1,88 @@ +import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; +import performanceBarApp from '~/performance_bar/components/performance_bar_app.vue'; +import PerformanceBarService from '~/performance_bar/services/performance_bar_service'; +import PerformanceBarStore from '~/performance_bar/stores/performance_bar_store'; + +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import MockAdapter from 'axios-mock-adapter'; + +describe('performance bar', () => { + let mock; + let vm; + + beforeEach(() => { + const store = new PerformanceBarStore(); + + mock = new MockAdapter(axios); + + mock.onGet('/-/peek/results').reply( + 200, + { + data: { + gc: { + invokes: 0, + invoke_time: '0.00', + use_size: 0, + total_size: 0, + total_object: 0, + gc_time: '0.00', + }, + host: { hostname: 'web-01' }, + }, + }, + {}, + ); + + vm = mountComponent(Vue.extend(performanceBarApp), { + store, + env: 'development', + requestId: '123', + peekUrl: '/-/peek/results', + profileUrl: '?lineprofiler=true', + }); + }); + + afterEach(() => { + vm.$destroy(); + mock.restore(); + }); + + it('sets the class to match the environment', () => { + expect(vm.$el.getAttribute('class')).toContain('development'); + }); + + describe('loadRequestDetails', () => { + beforeEach(() => { + spyOn(vm.store, 'addRequest').and.callThrough(); + }); + + it('does nothing if the request cannot be tracked', () => { + spyOn(vm.store, 'canTrackRequest').and.callFake(() => false); + + vm.loadRequestDetails('123', 'https://gitlab.com/'); + + expect(vm.store.addRequest).not.toHaveBeenCalled(); + }); + + it('adds the request immediately', () => { + vm.loadRequestDetails('123', 'https://gitlab.com/'); + + expect(vm.store.addRequest).toHaveBeenCalledWith( + '123', + 'https://gitlab.com/', + ); + }); + + it('makes an HTTP request for the request details', () => { + spyOn(PerformanceBarService, 'fetchRequestDetails').and.callThrough(); + + vm.loadRequestDetails('456', 'https://gitlab.com/'); + + expect(PerformanceBarService.fetchRequestDetails).toHaveBeenCalledWith( + '/-/peek/results', + '456', + ); + }); + }); +}); diff --git a/spec/javascripts/performance_bar/components/request_selector_spec.js b/spec/javascripts/performance_bar/components/request_selector_spec.js new file mode 100644 index 00000000000..6108a29f8c4 --- /dev/null +++ b/spec/javascripts/performance_bar/components/request_selector_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import requestSelector from '~/performance_bar/components/request_selector.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('request selector', () => { + const requests = [ + { id: '123', url: 'https://gitlab.com/' }, + { + id: '456', + url: 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1', + }, + { + id: '789', + url: + 'https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/1.json?serializer=widget', + }, + ]; + + let vm; + + beforeEach(() => { + vm = mountComponent(Vue.extend(requestSelector), { + requests, + currentRequest: requests[1], + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + function optionText(requestId) { + return vm.$el.querySelector(`[value='${requestId}']`).innerText.trim(); + } + + it('displays the last component of the path', () => { + expect(optionText(requests[2].id)).toEqual('1.json?serializer=widget'); + }); + + it('keeps the last two components of the path when the last component is numeric', () => { + expect(optionText(requests[1].id)).toEqual('merge_requests/1'); + }); + + it('ignores trailing slashes', () => { + expect(optionText(requests[0].id)).toEqual('gitlab.com'); + }); +}); diff --git a/spec/javascripts/performance_bar/components/simple_metric_spec.js b/spec/javascripts/performance_bar/components/simple_metric_spec.js new file mode 100644 index 00000000000..98b843e9711 --- /dev/null +++ b/spec/javascripts/performance_bar/components/simple_metric_spec.js @@ -0,0 +1,47 @@ +import Vue from 'vue'; +import simpleMetric from '~/performance_bar/components/simple_metric.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('simpleMetric', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('when the current request has no details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(simpleMetric), { + currentRequest: {}, + metric: 'gitaly', + }); + }); + + it('does not display details', () => { + expect(vm.$el.innerText).not.toContain('/'); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); + + describe('when the current request has details', () => { + beforeEach(() => { + vm = mountComponent(Vue.extend(simpleMetric), { + currentRequest: { + details: { gitaly: { duration: '123ms', calls: '456' } }, + }, + metric: 'gitaly', + }); + }); + + it('diplays details', () => { + expect(vm.$el.innerText.replace(/\s+/g, ' ')).toContain('123ms / 456'); + }); + + it('displays the metric name', () => { + expect(vm.$el.innerText).toContain('gitaly'); + }); + }); +}); diff --git a/spec/javascripts/project_select_combo_button_spec.js b/spec/javascripts/project_select_combo_button_spec.js index dda83645c92..1b65f767f96 100644 --- a/spec/javascripts/project_select_combo_button_spec.js +++ b/spec/javascripts/project_select_combo_button_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import ProjectSelectComboButton from '~/project_select_combo_button'; const fixturePath = 'static/project_select_combo_button.html.raw'; diff --git a/spec/javascripts/projects/project_new_spec.js b/spec/javascripts/projects/project_new_spec.js index 8731ce35d81..84515d2bf97 100644 --- a/spec/javascripts/projects/project_new_spec.js +++ b/spec/javascripts/projects/project_new_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import projectNew from '~/projects/project_new'; describe('New Project', () => { diff --git a/spec/javascripts/right_sidebar_spec.js b/spec/javascripts/right_sidebar_spec.js index 35bb630bf5d..80770a61011 100644 --- a/spec/javascripts/right_sidebar_spec.js +++ b/spec/javascripts/right_sidebar_spec.js @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, new-parens, no-return-assign, new-cap, vars-on-top, max-len */ +import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import '~/commons/bootstrap'; import axios from '~/lib/utils/axios_utils'; diff --git a/spec/javascripts/search_autocomplete_spec.js b/spec/javascripts/search_autocomplete_spec.js index 206f95abc1a..40115792652 100644 --- a/spec/javascripts/search_autocomplete_spec.js +++ b/spec/javascripts/search_autocomplete_spec.js @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren, max-len, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign, comma-dangle, object-shorthand, prefer-template, quotes, new-parens, vars-on-top, new-cap, max-len */ +import $ from 'jquery'; import '~/gl_dropdown'; import SearchAutocomplete from '~/search_autocomplete'; import '~/lib/utils/common_utils'; diff --git a/spec/javascripts/search_spec.js b/spec/javascripts/search_spec.js index 38e94d45e55..522851c584b 100644 --- a/spec/javascripts/search_spec.js +++ b/spec/javascripts/search_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Api from '~/api'; import Search from '~/pages/search/show/search'; diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index 5d6a885d4cc..b0d714cbefb 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -1,4 +1,5 @@ -import initCopyAsGFM from '~/behaviors/copy_as_gfm'; +import $ from 'jquery'; +import initCopyAsGFM from '~/behaviors/markdown/copy_as_gfm'; import ShortcutsIssuable from '~/shortcuts_issuable'; initCopyAsGFM(); diff --git a/spec/javascripts/shortcuts_spec.js b/spec/javascripts/shortcuts_spec.js index a2a609edef9..ee92295ef5e 100644 --- a/spec/javascripts/shortcuts_spec.js +++ b/spec/javascripts/shortcuts_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Shortcuts from '~/shortcuts'; describe('Shortcuts', () => { diff --git a/spec/javascripts/sidebar/assignee_title_spec.js b/spec/javascripts/sidebar/assignee_title_spec.js index ac93f918ce4..509edba2036 100644 --- a/spec/javascripts/sidebar/assignee_title_spec.js +++ b/spec/javascripts/sidebar/assignee_title_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import AssigneeTitle from '~/sidebar/components/assignees/assignee_title'; +import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue'; describe('AssigneeTitle component', () => { let component; diff --git a/spec/javascripts/sidebar/sidebar_move_issue_spec.js b/spec/javascripts/sidebar/sidebar_move_issue_spec.js index 0da5d91e376..d8e636cbdf0 100644 --- a/spec/javascripts/sidebar/sidebar_move_issue_spec.js +++ b/spec/javascripts/sidebar/sidebar_move_issue_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import _ from 'underscore'; import Vue from 'vue'; import SidebarMediator from '~/sidebar/sidebar_mediator'; diff --git a/spec/javascripts/smart_interval_spec.js b/spec/javascripts/smart_interval_spec.js index 7265e1b6cb5..a54219d58c2 100644 --- a/spec/javascripts/smart_interval_spec.js +++ b/spec/javascripts/smart_interval_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import _ from 'underscore'; import SmartInterval from '~/smart_interval'; diff --git a/spec/javascripts/syntax_highlight_spec.js b/spec/javascripts/syntax_highlight_spec.js index 763a15e710b..0d1fa680e00 100644 --- a/spec/javascripts/syntax_highlight_spec.js +++ b/spec/javascripts/syntax_highlight_spec.js @@ -1,5 +1,6 @@ /* eslint-disable space-before-function-paren, no-var, no-return-assign, quotes */ +import $ from 'jquery'; import syntaxHighlight from '~/syntax_highlight'; describe('Syntax Highlighter', function() { diff --git a/spec/javascripts/todos_spec.js b/spec/javascripts/todos_spec.js index 35871dddf89..898bbb3819b 100644 --- a/spec/javascripts/todos_spec.js +++ b/spec/javascripts/todos_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import * as urlUtils from '~/lib/utils/url_utility'; import Todos from '~/pages/dashboard/todos/index/todos'; import '~/lib/utils/common_utils'; diff --git a/spec/javascripts/toggle_buttons_spec.js b/spec/javascripts/toggle_buttons_spec.js index 205e396d682..17d0b94ebe0 100644 --- a/spec/javascripts/toggle_buttons_spec.js +++ b/spec/javascripts/toggle_buttons_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import setupToggleButtons from '~/toggle_buttons'; import getSetTimeoutPromise from './helpers/set_timeout_promise_helper'; diff --git a/spec/javascripts/u2f/authenticate_spec.js b/spec/javascripts/u2f/authenticate_spec.js index 4d15bcc4956..39c47a5c06d 100644 --- a/spec/javascripts/u2f/authenticate_spec.js +++ b/spec/javascripts/u2f/authenticate_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import U2FAuthenticate from '~/u2f/authenticate'; import 'vendor/u2f'; import MockU2FDevice from './mock_u2f_device'; diff --git a/spec/javascripts/u2f/register_spec.js b/spec/javascripts/u2f/register_spec.js index dbe89c2923c..136b4cad737 100644 --- a/spec/javascripts/u2f/register_spec.js +++ b/spec/javascripts/u2f/register_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import U2FRegister from '~/u2f/register'; import 'vendor/u2f'; import MockU2FDevice from './mock_u2f_device'; diff --git a/spec/javascripts/version_check_image_spec.js b/spec/javascripts/version_check_image_spec.js index 9637bd0414a..5f963e8c11e 100644 --- a/spec/javascripts/version_check_image_spec.js +++ b/spec/javascripts/version_check_image_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import VersionCheckImage from '~/version_check_image'; import ClassSpecHelper from './helpers/class_spec_helper'; diff --git a/spec/javascripts/vue_mr_widget/components/deployment_spec.js b/spec/javascripts/vue_mr_widget/components/deployment_spec.js new file mode 100644 index 00000000000..ff8d54c029f --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/deployment_spec.js @@ -0,0 +1,172 @@ +import Vue from 'vue'; +import * as urlUtils from '~/lib/utils/url_utility'; +import deploymentComponent from '~/vue_merge_request_widget/components/deployment.vue'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import { getTimeago } from '~/lib/utils/datetime_utility'; + +const deploymentMockData = { + id: 15, + name: 'review/diplo', + url: '/root/acets-review-apps/environments/15', + stop_url: '/root/acets-review-apps/environments/15/stop', + metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics', + external_url: 'http://diplo.', + external_url_formatted: 'diplo.', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', +}; +const createComponent = () => { + const Component = Vue.extend(deploymentComponent); + + return new Component({ + el: document.createElement('div'), + propsData: { deployment: { ...deploymentMockData } }, + }); +}; + +describe('Deployment component', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('deployTimeago', () => { + it('return formatted date', () => { + const readable = getTimeago().format(deploymentMockData.deployed_at); + expect(vm.deployTimeago).toEqual(readable); + }); + }); + + describe('hasExternalUrls', () => { + it('should return true', () => { + expect(vm.hasExternalUrls).toEqual(true); + }); + + it('should return false when deployment has no external_url_formatted', () => { + vm.deployment.external_url_formatted = null; + + expect(vm.hasExternalUrls).toEqual(false); + }); + + it('should return false when deployment has no external_url', () => { + vm.deployment.external_url = null; + + expect(vm.hasExternalUrls).toEqual(false); + }); + }); + + describe('hasDeploymentTime', () => { + it('should return true', () => { + expect(vm.hasDeploymentTime).toEqual(true); + }); + + it('should return false when deployment has no deployed_at', () => { + vm.deployment.deployed_at = null; + + expect(vm.hasDeploymentTime).toEqual(false); + }); + + it('should return false when deployment has no deployed_at_formatted', () => { + vm.deployment.deployed_at_formatted = null; + + expect(vm.hasDeploymentTime).toEqual(false); + }); + }); + + describe('hasDeploymentMeta', () => { + it('should return true', () => { + expect(vm.hasDeploymentMeta).toEqual(true); + }); + + it('should return false when deployment has no url', () => { + vm.deployment.url = null; + + expect(vm.hasDeploymentMeta).toEqual(false); + }); + + it('should return false when deployment has no name', () => { + vm.deployment.name = null; + + expect(vm.hasDeploymentMeta).toEqual(false); + }); + }); + }); + + describe('methods', () => { + describe('stopEnvironment', () => { + const url = '/foo/bar'; + const returnPromise = () => new Promise((resolve) => { + resolve({ + data: { + redirect_url: url, + }, + }); + }); + const mockStopEnvironment = () => { + vm.stopEnvironment(deploymentMockData); + return vm; + }; + + it('should show a confirm dialog and call service.stopEnvironment when confirmed', (done) => { + spyOn(window, 'confirm').and.returnValue(true); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true)); + spyOn(urlUtils, 'visitUrl').and.returnValue(true); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url); + setTimeout(() => { + expect(urlUtils.visitUrl).toHaveBeenCalledWith(url); + done(); + }, 333); + }); + + it('should show a confirm dialog but should not work if the dialog is rejected', () => { + spyOn(window, 'confirm').and.returnValue(false); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false)); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled(); + }); + }); + }); + + describe('template', () => { + let el; + + beforeEach(() => { + vm = createComponent(deploymentMockData); + el = vm.$el; + }); + + it('renders deployment name', () => { + expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deploymentMockData.url); + expect(el.querySelector('.js-deploy-meta').innerText).toContain(deploymentMockData.name); + }); + + it('renders external URL', () => { + expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deploymentMockData.external_url); + expect(el.querySelector('.js-deploy-url').innerText).toContain(deploymentMockData.external_url_formatted); + }); + + it('renders stop button', () => { + expect(el.querySelector('.btn')).not.toBeNull(); + }); + + it('renders deployment time', () => { + expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.deployTimeago); + }); + + it('renders metrics component', () => { + expect(el.querySelector('.js-mr-memory-usage')).not.toBeNull(); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js deleted file mode 100644 index 6a59dc3c87e..00000000000 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_deployment_spec.js +++ /dev/null @@ -1,179 +0,0 @@ -import Vue from 'vue'; -import * as urlUtils from '~/lib/utils/url_utility'; -import deploymentComponent from '~/vue_merge_request_widget/components/mr_widget_deployment'; -import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; -import { getTimeago } from '~/lib/utils/datetime_utility'; - -const deploymentMockData = [ - { - id: 15, - name: 'review/diplo', - url: '/root/acets-review-apps/environments/15', - stop_url: '/root/acets-review-apps/environments/15/stop', - metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', - metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics', - external_url: 'http://diplo.', - external_url_formatted: 'diplo.', - deployed_at: '2017-03-22T22:44:42.258Z', - deployed_at_formatted: 'Mar 22, 2017 10:44pm', - }, -]; -const createComponent = () => { - const Component = Vue.extend(deploymentComponent); - const mr = { - deployments: deploymentMockData, - }; - const service = {}; - - return new Component({ - el: document.createElement('div'), - propsData: { mr, service }, - }); -}; - -describe('MRWidgetDeployment', () => { - describe('props', () => { - it('should have props', () => { - const { mr, service } = deploymentComponent.props; - - expect(mr.type instanceof Object).toBeTruthy(); - expect(mr.required).toBeTruthy(); - - expect(service.type instanceof Object).toBeTruthy(); - expect(service.required).toBeTruthy(); - }); - }); - - describe('methods', () => { - let vm = createComponent(); - const deployment = deploymentMockData[0]; - - describe('formatDate', () => { - it('should work', () => { - const readable = getTimeago().format(deployment.deployed_at); - expect(vm.formatDate(deployment.deployed_at)).toEqual(readable); - }); - }); - - describe('hasExternalUrls', () => { - it('should return true', () => { - expect(vm.hasExternalUrls(deployment)).toBeTruthy(); - }); - - it('should return false when there is not enough information', () => { - expect(vm.hasExternalUrls()).toBeFalsy(); - expect(vm.hasExternalUrls({ external_url: 'Diplo' })).toBeFalsy(); - expect(vm.hasExternalUrls({ external_url_formatted: 'Diplo' })).toBeFalsy(); - }); - }); - - describe('hasDeploymentTime', () => { - it('should return true', () => { - expect(vm.hasDeploymentTime(deployment)).toBeTruthy(); - }); - - it('should return false when there is not enough information', () => { - expect(vm.hasDeploymentTime()).toBeFalsy(); - expect(vm.hasDeploymentTime({ deployed_at: 'Diplo' })).toBeFalsy(); - expect(vm.hasDeploymentTime({ deployed_at_formatted: 'Diplo' })).toBeFalsy(); - }); - }); - - describe('hasDeploymentMeta', () => { - it('should return true', () => { - expect(vm.hasDeploymentMeta(deployment)).toBeTruthy(); - }); - - it('should return false when there is not enough information', () => { - expect(vm.hasDeploymentMeta()).toBeFalsy(); - expect(vm.hasDeploymentMeta({ url: 'Diplo' })).toBeFalsy(); - expect(vm.hasDeploymentMeta({ name: 'Diplo' })).toBeFalsy(); - }); - }); - - describe('stopEnvironment', () => { - const url = '/foo/bar'; - const returnPromise = () => new Promise((resolve) => { - resolve({ - data: { - redirect_url: url, - }, - }); - }); - const mockStopEnvironment = () => { - vm.stopEnvironment(deploymentMockData); - return vm; - }; - - it('should show a confirm dialog and call service.stopEnvironment when confirmed', (done) => { - spyOn(window, 'confirm').and.returnValue(true); - spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true)); - spyOn(urlUtils, 'visitUrl').and.returnValue(true); - vm = mockStopEnvironment(); - - expect(window.confirm).toHaveBeenCalled(); - expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url); - setTimeout(() => { - expect(urlUtils.visitUrl).toHaveBeenCalledWith(url); - done(); - }, 333); - }); - - it('should show a confirm dialog but should not work if the dialog is rejected', () => { - spyOn(window, 'confirm').and.returnValue(false); - spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false)); - vm = mockStopEnvironment(); - - expect(window.confirm).toHaveBeenCalled(); - expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled(); - }); - }); - }); - - describe('template', () => { - let vm; - let el; - const [deployment] = deploymentMockData; - - beforeEach(() => { - vm = createComponent(deploymentMockData); - el = vm.$el; - }); - - it('should render template elements correctly', () => { - expect(el.classList.contains('mr-widget-heading')).toBeTruthy(); - expect(el.querySelector('.js-icon-link')).toBeDefined(); - expect(el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual(deployment.url); - expect(el.querySelector('.js-deploy-meta').innerText).toContain(deployment.name); - expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deployment.external_url); - expect(el.querySelector('.js-deploy-url').innerText).toContain(deployment.external_url_formatted); - expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.formatDate(deployment.deployed_at)); - expect(el.querySelector('.js-mr-memory-usage')).toBeDefined(); - expect(el.querySelector('button')).toBeDefined(); - }); - - it('should list multiple deployments', (done) => { - vm.mr.deployments.push(deployment); - vm.mr.deployments.push(deployment); - - Vue.nextTick(() => { - expect(el.querySelectorAll('.ci-widget').length).toEqual(3); - expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(3); - done(); - }); - }); - - it('should not have some elements when there is not enough data', (done) => { - vm.mr.deployments = [{}]; - - Vue.nextTick(() => { - expect(el.querySelectorAll('.js-deploy-meta').length).toEqual(0); - expect(el.querySelectorAll('.js-deploy-url').length).toEqual(0); - expect(el.querySelectorAll('.js-deploy-time').length).toEqual(0); - expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(0); - expect(el.querySelectorAll('.button').length).toEqual(0); - done(); - }); - }); - }); -}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js index 07ed7f7f532..31710551399 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import memoryUsageComponent from '~/vue_merge_request_widget/components/mr_widget_memory_usage'; +import MemoryUsage from '~/vue_merge_request_widget/components/memory_usage.vue'; import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; const url = '/root/acets-review-apps/environments/15/deployments/1/metrics'; @@ -34,7 +34,7 @@ const metricsMockData = { }; const createComponent = () => { - const Component = Vue.extend(memoryUsageComponent); + const Component = Vue.extend(MemoryUsage); return new Component({ el: document.createElement('div'), @@ -67,21 +67,9 @@ describe('MemoryUsage', () => { el = vm.$el; }); - describe('props', () => { - it('should have props with defaults', () => { - const { metricsUrl } = memoryUsageComponent.props; - const MetricsUrlTypeClass = metricsUrl.type; - - Vue.nextTick(() => { - expect(new MetricsUrlTypeClass() instanceof String).toBeTruthy(); - expect(metricsUrl.required).toBeTruthy(); - }); - }); - }); - describe('data', () => { it('should have default data', () => { - const data = memoryUsageComponent.data(); + const data = MemoryUsage.data(); expect(Array.isArray(data.memoryMetrics)).toBeTruthy(); expect(data.memoryMetrics.length).toBe(0); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js index dd907ad9015..d47815a5b5a 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_merge_when_pipeline_succeeds_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import mwpsComponent from '~/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; import eventHub from '~/vue_merge_request_widget/event_hub'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; @@ -25,12 +26,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => { targetBranchPath, targetBranch, }, - service: { - cancelAutomaticMerge() {}, - mergeResource: { - save() {}, - }, - }, + service: new MRWidgetService({}), }); }); @@ -90,18 +86,16 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => { describe('removeSourceBranch', () => { it('should set flag and call service then request main component to update the widget', (done) => { - spyOn(vm.service.mergeResource, 'save').and.returnValue(new Promise((resolve) => { - resolve({ - data: { - status: 'merge_when_pipeline_succeeds', - }, - }); + spyOn(vm.service, 'merge').and.returnValue(Promise.resolve({ + data: { + status: 'merge_when_pipeline_succeeds', + }, })); vm.removeSourceBranch(); setTimeout(() => { expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); - expect(vm.service.mergeResource.save).toHaveBeenCalledWith({ + expect(vm.service.merge).toHaveBeenCalledWith({ sha, merge_when_pipeline_succeeds: true, should_remove_source_branch: true, diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js index a8a02fa6b66..2a762c9336e 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_nothing_to_merge_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; -import nothingToMergeComponent from '~/vue_merge_request_widget/components/states/mr_widget_nothing_to_merge'; +import NothingToMerge from '~/vue_merge_request_widget/components/states/nothing_to_merge.vue'; -describe('MRWidgetNothingToMerge', () => { +describe('NothingToMerge', () => { describe('template', () => { - const Component = Vue.extend(nothingToMergeComponent); + const Component = Vue.extend(NothingToMerge); const newBlobPath = '/foo'; const vm = new Component({ el: document.createElement('div'), diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js index 073f26cc78f..58f683fb3e6 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -517,13 +517,9 @@ describe('MRWidgetReadyToMerge', () => { describe('Remove source branch checkbox', () => { describe('when user can merge but cannot delete branch', () => { - it('isRemoveSourceBranchButtonDisabled should be true', () => { - expect(vm.isRemoveSourceBranchButtonDisabled).toBe(true); - }); - it('should be disabled in the rendered output', () => { const checkboxElement = vm.$el.querySelector('#remove-source-branch-input'); - expect(checkboxElement.getAttribute('disabled')).toBe('disabled'); + expect(checkboxElement).toBeNull(); }); }); @@ -540,7 +536,7 @@ describe('MRWidgetReadyToMerge', () => { it('should be enabled in rendered output', () => { const checkboxElement = this.customVm.$el.querySelector('#remove-source-branch-input'); - expect(checkboxElement.getAttribute('disabled')).toBeNull(); + expect(checkboxElement).not.toBeNull(); }); }); }); @@ -549,12 +545,12 @@ describe('MRWidgetReadyToMerge', () => { describe('when allowed to merge', () => { beforeEach(() => { vm = createComponent({ - mr: { isMergeAllowed: true }, + mr: { isMergeAllowed: true, canRemoveSourceBranch: true }, }); }); it('shows remove source branch checkbox', () => { - expect(vm.$el.querySelector('.js-remove-source-branch-checkbox')).toBeDefined(); + expect(vm.$el.querySelector('.js-remove-source-branch-checkbox')).not.toBeNull(); }); it('shows modify commit message button', () => { diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js index 4c67504b642..25684861724 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js @@ -1,16 +1,17 @@ import Vue from 'vue'; -import shaMismatchComponent from '~/vue_merge_request_widget/components/states/mr_widget_sha_mismatch'; +import ShaMismatch from '~/vue_merge_request_widget/components/states/sha_mismatch.vue'; -describe('MRWidgetSHAMismatch', () => { +describe('ShaMismatch', () => { describe('template', () => { - const Component = Vue.extend(shaMismatchComponent); + const Component = Vue.extend(ShaMismatch); const vm = new Component({ el: document.createElement('div'), }); it('should have correct elements', () => { expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); - expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed. Please reload the page and review the changes before merging'); + expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed.'); + expect(vm.$el.innerText).toContain('Please reload the page and review the changes before merging.'); }); }); }); diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js index fe87f110354..046968fbc1f 100644 --- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import unresolvedDiscussionsComponent from '~/vue_merge_request_widget/components/states/mr_widget_unresolved_discussions'; +import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue'; -describe('MRWidgetUnresolvedDiscussions', () => { +describe('UnresolvedDiscussions', () => { describe('props', () => { it('should have props', () => { - const { mr } = unresolvedDiscussionsComponent.props; + const { mr } = UnresolvedDiscussions.props; expect(mr.type instanceof Object).toBeTruthy(); expect(mr.required).toBeTruthy(); @@ -17,7 +17,7 @@ describe('MRWidgetUnresolvedDiscussions', () => { const path = 'foo/bar'; beforeEach(() => { - const Component = Vue.extend(unresolvedDiscussionsComponent); + const Component = Vue.extend(UnresolvedDiscussions); const mr = { createIssueToResolveDiscussionsPath: path, }; diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index ebe151ac3b1..e55c7649d40 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -81,14 +81,46 @@ describe('mrWidgetOptions', () => { }); }); - describe('shouldRenderDeployments', () => { - it('should return false for the initial data', () => { - expect(vm.shouldRenderDeployments).toBeFalsy(); + describe('shouldRenderSourceBranchRemovalStatus', () => { + beforeEach(() => { + vm.mr.state = 'readyToMerge'; + }); + + it('should return true when cannot remove source branch and branch will be removed', () => { + vm.mr.canRemoveSourceBranch = false; + vm.mr.shouldRemoveSourceBranch = true; + + expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(true); + }); + + it('should return false when can remove source branch and branch will be removed', () => { + vm.mr.canRemoveSourceBranch = true; + vm.mr.shouldRemoveSourceBranch = true; + + expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); + }); + + it('should return false when cannot remove source branch and branch will not be removed', () => { + vm.mr.canRemoveSourceBranch = false; + vm.mr.shouldRemoveSourceBranch = false; + + expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); }); - it('should return true if there is deployments', () => { - vm.mr.deployments.push({}, {}); - expect(vm.shouldRenderDeployments).toBeTruthy(); + it('should return false when in merged state', () => { + vm.mr.canRemoveSourceBranch = false; + vm.mr.shouldRemoveSourceBranch = true; + vm.mr.state = 'merged'; + + expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); + }); + + it('should return false when in nothing to merge state', () => { + vm.mr.canRemoveSourceBranch = false; + vm.mr.shouldRemoveSourceBranch = true; + vm.mr.state = 'nothingToMerge'; + + expect(vm.shouldRenderSourceBranchRemovalStatus).toEqual(false); }); }); }); @@ -146,16 +178,16 @@ describe('mrWidgetOptions', () => { describe('fetchDeployments', () => { it('should fetch deployments', (done) => { - spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ deployment: 1 }])); + spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ id: 1 }])); vm.fetchDeployments(); setTimeout(() => { expect(vm.service.fetchDeployments).toHaveBeenCalled(); expect(vm.mr.deployments.length).toEqual(1); - expect(vm.mr.deployments[0].deployment).toEqual(1); + expect(vm.mr.deployments[0].id).toBe(1); done(); - }, 333); + }); }); }); @@ -325,34 +357,6 @@ describe('mrWidgetOptions', () => { }); }); - describe('components', () => { - it('should register all components', () => { - const comps = mrWidgetOptions.components; - expect(comps['mr-widget-header']).toBeDefined(); - expect(comps['mr-widget-merge-help']).toBeDefined(); - expect(comps['mr-widget-pipeline']).toBeDefined(); - expect(comps['mr-widget-deployment']).toBeDefined(); - expect(comps['mr-widget-related-links']).toBeDefined(); - expect(comps['mr-widget-merged']).toBeDefined(); - expect(comps['mr-widget-closed']).toBeDefined(); - expect(comps['mr-widget-merging']).toBeDefined(); - expect(comps['mr-widget-failed-to-merge']).toBeDefined(); - expect(comps['mr-widget-wip']).toBeDefined(); - expect(comps['mr-widget-archived']).toBeDefined(); - expect(comps['mr-widget-conflicts']).toBeDefined(); - expect(comps['mr-widget-nothing-to-merge']).toBeDefined(); - expect(comps['mr-widget-not-allowed']).toBeDefined(); - expect(comps['mr-widget-missing-branch']).toBeDefined(); - expect(comps['mr-widget-ready-to-merge']).toBeDefined(); - expect(comps['mr-widget-checking']).toBeDefined(); - expect(comps['mr-widget-unresolved-discussions']).toBeDefined(); - expect(comps['mr-widget-pipeline-blocked']).toBeDefined(); - expect(comps['mr-widget-pipeline-failed']).toBeDefined(); - expect(comps['mr-widget-merge-when-pipeline-succeeds']).toBeDefined(); - expect(comps['mr-widget-maintainer-edit']).toBeDefined(); - }); - }); - describe('rendering relatedLinks', () => { beforeEach((done) => { vm.mr.relatedLinks = { @@ -379,4 +383,66 @@ describe('mrWidgetOptions', () => { }); }); }); + + describe('rendering source branch removal status', () => { + it('renders when user cannot remove branch and branch should be removed', (done) => { + vm.mr.canRemoveSourceBranch = false; + vm.mr.shouldRemoveSourceBranch = true; + vm.mr.state = 'readyToMerge'; + + vm.$nextTick(() => { + const tooltip = vm.$el.querySelector('.fa-question-circle'); + + expect(vm.$el.textContent).toContain('Removes source branch'); + expect(tooltip.getAttribute('data-original-title')).toBe( + 'A user with write access to the source branch selected this option', + ); + + done(); + }); + }); + + it('does not render in merged state', (done) => { + vm.mr.canRemoveSourceBranch = false; + vm.mr.shouldRemoveSourceBranch = true; + vm.mr.state = 'merged'; + + vm.$nextTick(() => { + expect(vm.$el.textContent).toContain('The source branch has been removed'); + expect(vm.$el.textContent).not.toContain('Removes source branch'); + + done(); + }); + }); + }); + + describe('rendering deployments', () => { + const deploymentMockData = { + id: 15, + name: 'review/diplo', + url: '/root/acets-review-apps/environments/15', + stop_url: '/root/acets-review-apps/environments/15/stop', + metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/acets-review-apps/environments/15/metrics', + external_url: 'http://diplo.', + external_url_formatted: 'diplo.', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + }; + + beforeEach((done) => { + vm.mr.deployments.push({ + ...deploymentMockData, + }, { + ...deploymentMockData, + id: deploymentMockData.id + 1, + }); + + vm.$nextTick(done); + }); + + it('renders multiple deployments', () => { + expect(vm.$el.querySelectorAll('.deploy-heading').length).toBe(2); + }); + }); }); diff --git a/spec/javascripts/vue_shared/components/gl_modal_spec.js b/spec/javascripts/vue_shared/components/gl_modal_spec.js index 2805d9a7003..85cb1b90fc6 100644 --- a/spec/javascripts/vue_shared/components/gl_modal_spec.js +++ b/spec/javascripts/vue_shared/components/gl_modal_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import GlModal from '~/vue_shared/components/gl_modal.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js index 5f980bbf36c..69034975422 100644 --- a/spec/javascripts/vue_shared/components/markdown/field_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import fieldComponent from '~/vue_shared/components/markdown/field.vue'; diff --git a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js index 818ef0af3c2..3e708f865c8 100644 --- a/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js +++ b/spec/javascripts/vue_shared/components/markdown/toolbar_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import toolbar from '~/vue_shared/components/markdown/toolbar.vue'; -import mountComponent from '../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; describe('toolbar', () => { let vm; diff --git a/spec/javascripts/vue_shared/components/memory_graph_spec.js b/spec/javascripts/vue_shared/components/memory_graph_spec.js index d46a3f2328e..73a69df019e 100644 --- a/spec/javascripts/vue_shared/components/memory_graph_spec.js +++ b/spec/javascripts/vue_shared/components/memory_graph_spec.js @@ -1,12 +1,12 @@ import Vue from 'vue'; -import memoryGraphComponent from '~/vue_shared/components/memory_graph'; +import MemoryGraph from '~/vue_shared/components/memory_graph.vue'; import { mockMetrics, mockMedian, mockMedianIndex } from './mock_data'; const defaultHeight = '25'; const defaultWidth = '100'; const createComponent = () => { - const Component = Vue.extend(memoryGraphComponent); + const Component = Vue.extend(MemoryGraph); return new Component({ el: document.createElement('div'), @@ -32,29 +32,9 @@ describe('MemoryGraph', () => { el = vm.$el; }); - describe('props', () => { - it('should have props with defaults', (done) => { - const { metrics, deploymentTime, width, height } = memoryGraphComponent.props; - - Vue.nextTick(() => { - const typeClassMatcher = (propItem, expectedType) => { - const PropItemTypeClass = propItem.type; - expect(new PropItemTypeClass() instanceof expectedType).toBeTruthy(); - expect(propItem.required).toBeTruthy(); - }; - - typeClassMatcher(metrics, Array); - typeClassMatcher(deploymentTime, Number); - typeClassMatcher(width, String); - typeClassMatcher(height, String); - done(); - }); - }); - }); - describe('data', () => { it('should have default data', () => { - const data = memoryGraphComponent.data(); + const data = MemoryGraph.data(); const dataValidator = (dataItem, expectedType, defaultVal) => { expect(typeof dataItem).toBe(expectedType); expect(dataItem).toBe(defaultVal); diff --git a/spec/javascripts/vue_shared/components/modal_spec.js b/spec/javascripts/vue_shared/components/modal_spec.js index 8412df74f98..d01a94c25e5 100644 --- a/spec/javascripts/vue_shared/components/modal_spec.js +++ b/spec/javascripts/vue_shared/components/modal_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import modal from '~/vue_shared/components/modal.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js index 67056793a20..6fe95153204 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js @@ -3,9 +3,9 @@ import Vue from 'vue'; import LabelsSelect from '~/labels_select'; import baseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = (config = mockConfig) => { const Component = Vue.extend(baseComponent); @@ -37,6 +37,32 @@ describe('BaseComponent', () => { vmNonEditable.$destroy(); }); }); + + describe('createLabelTitle', () => { + it('returns `Create project label` when `isProject` prop is true', () => { + expect(vm.createLabelTitle).toBe('Create project label'); + }); + + it('return `Create group label` when `isProject` prop is false', () => { + const mockConfigGroup = Object.assign({}, mockConfig, { isProject: false }); + const vmGroup = createComponent(mockConfigGroup); + expect(vmGroup.createLabelTitle).toBe('Create group label'); + vmGroup.$destroy(); + }); + }); + + describe('manageLabelsTitle', () => { + it('returns `Manage project labels` when `isProject` prop is true', () => { + expect(vm.manageLabelsTitle).toBe('Manage project labels'); + }); + + it('return `Manage group labels` when `isProject` prop is false', () => { + const mockConfigGroup = Object.assign({}, mockConfig, { isProject: false }); + const vmGroup = createComponent(mockConfigGroup); + expect(vmGroup.manageLabelsTitle).toBe('Manage group labels'); + vmGroup.$destroy(); + }); + }); }); describe('methods', () => { diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js index ec63ac306d0..f25c70db125 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const componentConfig = Object.assign({}, mockConfig, { fieldName: 'label_id[]', diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index f07aefb2f87..ce559fe0335 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -2,14 +2,16 @@ import Vue from 'vue'; import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; -import { mockSuggestedColors } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockSuggestedColors } from './mock_data'; -const createComponent = () => { +const createComponent = (headerTitle) => { const Component = Vue.extend(dropdownCreateLabelComponent); - return mountComponent(Component); + return mountComponent(Component, { + headerTitle, + }); }; describe('DropdownCreateLabelComponent', () => { @@ -41,11 +43,19 @@ describe('DropdownCreateLabelComponent', () => { expect(backButtonEl.querySelector('.fa-arrow-left')).not.toBe(null); }); - it('renders component header element', () => { + it('renders component header element as `Create new label` when `headerTitle` prop is not provided', () => { const headerEl = vm.$el.querySelector('.dropdown-title'); expect(headerEl.innerText.trim()).toContain('Create new label'); }); + it('renders component header element with value of `headerTitle` prop', () => { + const headerTitle = 'Create project label'; + const vmWithHeaderTitle = createComponent(headerTitle); + const headerEl = vmWithHeaderTitle.$el.querySelector('.dropdown-title'); + expect(headerEl.innerText.trim()).toContain(headerTitle); + vmWithHeaderTitle.$destroy(); + }); + it('renders `Close` button on component header', () => { const closeButtonEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-close'); expect(closeButtonEl).not.toBe(null); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js index 809e0327b1c..debeab25bd6 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js @@ -2,19 +2,27 @@ import Vue from 'vue'; import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; -import { mockConfig } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig } from './mock_data'; -const createComponent = (labelsWebUrl = mockConfig.labelsWebUrl) => { +const createComponent = ( + labelsWebUrl = mockConfig.labelsWebUrl, + createLabelTitle, + manageLabelsTitle, +) => { const Component = Vue.extend(dropdownFooterComponent); return mountComponent(Component, { labelsWebUrl, + createLabelTitle, + manageLabelsTitle, }); }; describe('DropdownFooterComponent', () => { + const createLabelTitle = 'Create project label'; + const manageLabelsTitle = 'Manage project labels'; let vm; beforeEach(() => { @@ -26,17 +34,35 @@ describe('DropdownFooterComponent', () => { }); describe('template', () => { - it('renders `Create new label` link element', () => { + it('renders link element with `Create new label` when `createLabelTitle` prop is not provided', () => { const createLabelEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-toggle-page'); expect(createLabelEl).not.toBeNull(); expect(createLabelEl.innerText.trim()).toBe('Create new label'); }); - it('renders `Manage labels` link element', () => { + it('renders link element with value of `createLabelTitle` prop', () => { + const vmWithCreateLabelTitle = createComponent(mockConfig.labelsWebUrl, createLabelTitle); + const createLabelEl = vmWithCreateLabelTitle.$el.querySelector('.dropdown-footer-list .dropdown-toggle-page'); + expect(createLabelEl.innerText.trim()).toBe(createLabelTitle); + vmWithCreateLabelTitle.$destroy(); + }); + + it('renders link element with `Manage labels` when `manageLabelsTitle` prop is not provided', () => { const manageLabelsEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-external-link'); expect(manageLabelsEl).not.toBeNull(); expect(manageLabelsEl.getAttribute('href')).toBe(vm.labelsWebUrl); expect(manageLabelsEl.innerText.trim()).toBe('Manage labels'); }); + + it('renders link element with value of `manageLabelsTitle` prop', () => { + const vmWithManageLabelsTitle = createComponent( + mockConfig.labelsWebUrl, + createLabelTitle, + manageLabelsTitle, + ); + const manageLabelsEl = vmWithManageLabelsTitle.$el.querySelector('.dropdown-footer-list .dropdown-external-link'); + expect(manageLabelsEl.innerText.trim()).toBe(manageLabelsTitle); + vmWithManageLabelsTitle.$destroy(); + }); }); }); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js index 325fa47c957..cdf234bb0c4 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = () => { const Component = Vue.extend(dropdownHeaderComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js index 703b87498c7..88733922a59 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue'; -import { mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockLabels } from './mock_data'; const createComponent = (name = 'label_id[]', label = mockLabels[0]) => { const Component = Vue.extend(dropdownHiddenInputComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js index 69e11d966c2..57608d957e7 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = () => { const Component = Vue.extend(dropdownSearchInputComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js index c3580933072..7c3d2711f65 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; const createComponent = (canEdit = true) => { const Component = Vue.extend(dropdownTitleComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index 93b42795bea..39040670a87 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; -import { mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockLabels } from './mock_data'; const createComponent = (labels = mockLabels) => { const Component = Vue.extend(dropdownValueCollapsedComponent); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js index 66e0957b431..4397b00acfa 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -2,9 +2,9 @@ import Vue from 'vue'; import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; -import { mockConfig, mockLabels } from './mock_data'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import mountComponent from '../../../../helpers/vue_mount_component_helper'; +import { mockConfig, mockLabels } from './mock_data'; const createComponent = ( labels = mockLabels, diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js index e9008c29b22..3fcb91b6f5e 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js @@ -34,6 +34,7 @@ export const mockSuggestedColors = [ export const mockConfig = { showCreate: true, + isProject: true, abilityName: 'issue', context: { labels: mockLabels, diff --git a/spec/javascripts/vue_shared/directives/tooltip_spec.js b/spec/javascripts/vue_shared/directives/tooltip_spec.js index b1b3071527b..4a644913e44 100644 --- a/spec/javascripts/vue_shared/directives/tooltip_spec.js +++ b/spec/javascripts/vue_shared/directives/tooltip_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Vue from 'vue'; import tooltip from '~/vue_shared/directives/tooltip'; diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js index 8edba1f47a3..7fe3bd92049 100644 --- a/spec/javascripts/zen_mode_spec.js +++ b/spec/javascripts/zen_mode_spec.js @@ -1,3 +1,4 @@ +import $ from 'jquery'; import Mousetrap from 'mousetrap'; import Dropzone from 'dropzone'; import ZenMode from '~/zen_mode'; diff --git a/spec/lib/api/helpers/related_resources_helpers_spec.rb b/spec/lib/api/helpers/related_resources_helpers_spec.rb new file mode 100644 index 00000000000..b918301f1cb --- /dev/null +++ b/spec/lib/api/helpers/related_resources_helpers_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe API::Helpers::RelatedResourcesHelpers do + subject(:helpers) do + Class.new.include(described_class).new + end + + describe '#expose_url' do + let(:path) { '/api/v4/awesome_endpoint' } + subject(:url) { helpers.expose_url(path) } + + def stub_default_url_options(protocol: 'http', host: 'example.com', port: nil) + expect(Gitlab::Application.routes).to receive(:default_url_options) + .and_return(protocol: protocol, host: host, port: port) + end + + it 'respects the protocol if it is HTTP' do + stub_default_url_options(protocol: 'http') + + is_expected.to start_with('http://') + end + + it 'respects the protocol if it is HTTPS' do + stub_default_url_options(protocol: 'https') + + is_expected.to start_with('https://') + end + + it 'accepts port to be nil' do + stub_default_url_options(port: nil) + + is_expected.to start_with('http://example.com/') + end + + it 'includes port if provided' do + stub_default_url_options(port: 8080) + + is_expected.to start_with('http://example.com:8080/') + end + end +end diff --git a/spec/lib/banzai/filter/relative_link_filter_spec.rb b/spec/lib/banzai/filter/relative_link_filter_spec.rb index 3ca4652f7cc..ba8dc68ceda 100644 --- a/spec/lib/banzai/filter/relative_link_filter_spec.rb +++ b/spec/lib/banzai/filter/relative_link_filter_spec.rb @@ -217,6 +217,23 @@ describe Banzai::Filter::RelativeLinkFilter do end end + context 'when ref name contains special chars' do + let(:ref) {'mark#\'@],+;-._/#@!$&()+down'} + + it 'correctly escapes the ref' do + # Adressable won't escape the '#', so we do this manually + ref_escaped = 'mark%23\'@%5D,+;-._/%23@!$&()+down' + + # Stub this method so the branch doesn't actually need to be in the repo + allow_any_instance_of(described_class).to receive(:uri_type).and_return(:raw) + + doc = filter(link('files/images/logo-black.png')) + + expect(doc.at_css('a')['href']) + .to eq "/#{project_path}/raw/#{ref_escaped}/files/images/logo-black.png" + end + end + context 'when requested path is a directory with space in the repo' do let(:ref) { 'master' } let(:commit) { project.commit('38008cb17ce1466d8fec2dfa6f6ab8dcfe5cf49e') } diff --git a/spec/lib/gitlab/background_migration/add_merge_request_diff_commits_count_spec.rb b/spec/lib/gitlab/background_migration/add_merge_request_diff_commits_count_spec.rb index 21a791f5695..c43ed72038e 100644 --- a/spec/lib/gitlab/background_migration/add_merge_request_diff_commits_count_spec.rb +++ b/spec/lib/gitlab/background_migration/add_merge_request_diff_commits_count_spec.rb @@ -37,6 +37,18 @@ describe Gitlab::BackgroundMigration::AddMergeRequestDiffCommitsCount, :migratio expect(diff.reload.commits_count).to eq(0) end + it 'skips diffs that have commits_count already set' do + timestamp = 2.days.ago + diff = merge_request_diffs_table.create!( + merge_request_id: merge_request.id, + commits_count: 0, + updated_at: timestamp) + + subject.perform(diff.id, diff.id) + + expect(diff.reload.updated_at).to be_within(1.second).of(timestamp) + end + it 'migrates multiple diffs to the correct values' do diffs = Array.new(3).map.with_index { |_, i| create_diff!(i, commits: 3) } diff --git a/spec/lib/gitlab/bare_repository_import/repository_spec.rb b/spec/lib/gitlab/bare_repository_import/repository_spec.rb index 9f42cf1dfca..5cb1f4deb5f 100644 --- a/spec/lib/gitlab/bare_repository_import/repository_spec.rb +++ b/spec/lib/gitlab/bare_repository_import/repository_spec.rb @@ -61,7 +61,7 @@ describe ::Gitlab::BareRepositoryImport::Repository do let(:wiki_path) { File.join(root_path, "#{hashed_path}.wiki.git") } before do - gitlab_shell.add_repository(repository_storage, hashed_path) + gitlab_shell.create_repository(repository_storage, hashed_path) repository = Rugged::Repository.new(repo_path) repository.config['gitlab.fullpath'] = 'to/repo' end diff --git a/spec/lib/gitlab/ci/trace_spec.rb b/spec/lib/gitlab/ci/trace_spec.rb index 448c6fb57dd..3a9371ed2e8 100644 --- a/spec/lib/gitlab/ci/trace_spec.rb +++ b/spec/lib/gitlab/ci/trace_spec.rb @@ -510,6 +510,28 @@ describe Gitlab::Ci::Trace do 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 diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb new file mode 100644 index 00000000000..cc1257484d2 --- /dev/null +++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Gitlab::Ci::Variables::Collection::Item do + let(:variable) do + { key: 'VAR', value: 'something', public: true } + end + + describe '.fabricate' do + it 'supports using a hash' do + resource = described_class.fabricate(variable) + + expect(resource).to be_a(described_class) + expect(resource).to eq variable + end + + it 'supports using an active record resource' do + variable = create(:ci_variable, key: 'CI_VAR', value: '123') + resource = described_class.fabricate(variable) + + expect(resource).to be_a(described_class) + expect(resource).to eq(key: 'CI_VAR', value: '123', public: false) + end + + it 'supports using another collection item' do + item = described_class.new(**variable) + + resource = described_class.fabricate(item) + + expect(resource).to be_a(described_class) + expect(resource).to eq variable + expect(resource.object_id).not_to eq item.object_id + end + end + + describe '#==' do + it 'compares a hash representation of a variable' do + expect(described_class.new(**variable) == variable).to be true + end + end + + describe '#[]' do + it 'behaves like a hash accessor' do + item = described_class.new(**variable) + + expect(item[:key]).to eq 'VAR' + end + end + + describe '#to_hash' do + it 'returns a hash representation of a collection item' do + expect(described_class.new(**variable).to_hash).to eq variable + end + end +end diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb new file mode 100644 index 00000000000..90b6e178242 --- /dev/null +++ b/spec/lib/gitlab/ci/variables/collection_spec.rb @@ -0,0 +1,99 @@ +require 'spec_helper' + +describe Gitlab::Ci::Variables::Collection do + describe '.new' do + it 'can be initialized with an array' do + variable = { key: 'VAR', value: 'value', public: true } + + collection = described_class.new([variable]) + + expect(collection.first.to_hash).to eq variable + end + + it 'can be initialized without an argument' do + expect(subject).to be_none + end + end + + describe '#append' do + it 'appends a hash' do + subject.append(key: 'VARIABLE', value: 'something') + + expect(subject).to be_one + end + + it 'appends a Ci::Variable' do + subject.append(build(:ci_variable)) + + expect(subject).to be_one + end + + it 'appends an internal resource' do + collection = described_class.new([{ key: 'TEST', value: 1 }]) + + subject.append(collection.first) + + expect(subject).to be_one + end + + it 'returns self' do + expect(subject.append(key: 'VAR', value: 'test')) + .to eq subject + end + end + + describe '#concat' do + it 'appends all elements from an array' do + collection = described_class.new([{ key: 'VAR_1', value: '1' }]) + variables = [{ key: 'VAR_2', value: '2' }, { key: 'VAR_3', value: '3' }] + + collection.concat(variables) + + expect(collection).to include(key: 'VAR_1', value: '1', public: true) + expect(collection).to include(key: 'VAR_2', value: '2', public: true) + expect(collection).to include(key: 'VAR_3', value: '3', public: true) + end + + it 'appends all elements from other collection' do + collection = described_class.new([{ key: 'VAR_1', value: '1' }]) + additional = described_class.new([{ key: 'VAR_2', value: '2' }, + { key: 'VAR_3', value: '3' }]) + + collection.concat(additional) + + expect(collection).to include(key: 'VAR_1', value: '1', public: true) + expect(collection).to include(key: 'VAR_2', value: '2', public: true) + expect(collection).to include(key: 'VAR_3', value: '3', public: true) + end + + it 'returns self' do + expect(subject.concat([key: 'VAR', value: 'test'])) + .to eq subject + end + end + + describe '#+' do + it 'makes it possible to combine with an array' do + collection = described_class.new([{ key: 'TEST', value: 1 }]) + variables = [{ key: 'TEST', value: 'something' }] + + expect((collection + variables).count).to eq 2 + end + + it 'makes it possible to combine with another collection' do + collection = described_class.new([{ key: 'TEST', value: 1 }]) + other = described_class.new([{ key: 'TEST', value: 2 }]) + + expect((collection + other).count).to eq 2 + end + end + + describe '#to_runner_variables' do + it 'creates an array of hashes in a runner-compatible format' do + collection = described_class.new([{ key: 'TEST', value: 1 }]) + + expect(collection.to_runner_variables) + .to eq [{ key: 'TEST', value: 1, public: true }] + end + end +end diff --git a/spec/lib/gitlab/conflict/file_collection_spec.rb b/spec/lib/gitlab/conflict/file_collection_spec.rb index 5944ce8049a..c93912db411 100644 --- a/spec/lib/gitlab/conflict/file_collection_spec.rb +++ b/spec/lib/gitlab/conflict/file_collection_spec.rb @@ -10,6 +10,38 @@ describe Gitlab::Conflict::FileCollection do end end + describe '#cache' do + it 'specifies a custom namespace with the merge request commit ids' do + our_commit = merge_request.source_branch_head.raw + their_commit = merge_request.target_branch_head.raw + custom_namespace = "#{our_commit.id}:#{their_commit.id}" + + expect(file_collection.send(:cache).namespace).to include(custom_namespace) + end + end + + describe '#can_be_resolved_in_ui?' do + it 'returns true if conflicts for this collection can be resolved in the UI' do + expect(file_collection.can_be_resolved_in_ui?).to be true + end + + it "returns false if conflicts for this collection can't be resolved in the UI" do + expect(file_collection).to receive(:files).and_raise(Gitlab::Git::Conflict::Resolver::ConflictSideMissing) + + expect(file_collection.can_be_resolved_in_ui?).to be false + end + + it 'caches the result' do + expect(file_collection).to receive(:files).twice.and_call_original + + expect(file_collection.can_be_resolved_in_ui?).to be true + + expect(file_collection).not_to receive(:files) + + expect(file_collection.can_be_resolved_in_ui?).to be true + end + end + describe '#default_commit_message' do it 'matches the format of the git CLI commit message' do expect(file_collection.default_commit_message).to eq(<<EOM.chomp) diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 1de3a14b809..a41b7f4e104 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -67,17 +67,35 @@ describe Gitlab::Database::MigrationHelpers do model.add_concurrent_index(:users, :foo, unique: true) end + + it 'does nothing if the index exists already' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(true) + expect(model).not_to receive(:add_index) + + model.add_concurrent_index(:users, :foo, unique: true) + end end context 'using MySQL' do - it 'creates a regular index' do - expect(Gitlab::Database).to receive(:postgresql?).and_return(false) + before do + allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + end + it 'creates a regular index' do expect(model).to receive(:add_index) .with(:users, :foo, {}) model.add_concurrent_index(:users, :foo) end + + it 'does nothing if the index exists already' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { unique: true }).and_return(true) + expect(model).not_to receive(:add_index) + + model.add_concurrent_index(:users, :foo, unique: true) + end end end @@ -95,6 +113,7 @@ describe Gitlab::Database::MigrationHelpers do context 'outside a transaction' do before do allow(model).to receive(:transaction_open?).and_return(false) + allow(model).to receive(:index_exists?).and_return(true) end context 'using PostgreSQL' do @@ -103,18 +122,41 @@ describe Gitlab::Database::MigrationHelpers do allow(model).to receive(:disable_statement_timeout) end - it 'removes the index concurrently by column name' do - expect(model).to receive(:remove_index) - .with(:users, { algorithm: :concurrently, column: :foo }) + describe 'by column name' do + it 'removes the index concurrently' do + expect(model).to receive(:remove_index) + .with(:users, { algorithm: :concurrently, column: :foo }) - model.remove_concurrent_index(:users, :foo) + model.remove_concurrent_index(:users, :foo) + end + + it 'does nothing if the index does not exist' do + expect(model).to receive(:index_exists?) + .with(:users, :foo, { algorithm: :concurrently, unique: true }).and_return(false) + expect(model).not_to receive(:remove_index) + + model.remove_concurrent_index(:users, :foo, unique: true) + end end - it 'removes the index concurrently by index name' do - expect(model).to receive(:remove_index) - .with(:users, { algorithm: :concurrently, name: "index_x_by_y" }) + describe 'by index name' do + before do + allow(model).to receive(:index_exists_by_name?).with(:users, "index_x_by_y").and_return(true) + end + + it 'removes the index concurrently by index name' do + expect(model).to receive(:remove_index) + .with(:users, { algorithm: :concurrently, name: "index_x_by_y" }) + + model.remove_concurrent_index_by_name(:users, "index_x_by_y") + end + + it 'does nothing if the index does not exist' do + expect(model).to receive(:index_exists_by_name?).with(:users, "index_x_by_y").and_return(false) + expect(model).not_to receive(:remove_index) - model.remove_concurrent_index_by_name(:users, "index_x_by_y") + model.remove_concurrent_index_by_name(:users, "index_x_by_y") + end end end @@ -141,6 +183,10 @@ describe Gitlab::Database::MigrationHelpers do end describe '#add_concurrent_foreign_key' do + before do + allow(model).to receive(:foreign_key_exists?).and_return(false) + end + context 'inside a transaction' do it 'raises an error' do expect(model).to receive(:transaction_open?).and_return(true) @@ -157,14 +203,23 @@ describe Gitlab::Database::MigrationHelpers do end context 'using MySQL' do - it 'creates a regular foreign key' do + before do allow(Gitlab::Database).to receive(:mysql?).and_return(true) + end + it 'creates a regular foreign key' do expect(model).to receive(:add_foreign_key) .with(:projects, :users, column: :user_id, on_delete: :cascade) model.add_concurrent_foreign_key(:projects, :users, column: :user_id) end + + it 'does not create a foreign key if it exists already' do + expect(model).to receive(:foreign_key_exists?).with(:projects, :users, column: :user_id).and_return(true) + expect(model).not_to receive(:add_foreign_key) + + model.add_concurrent_foreign_key(:projects, :users, column: :user_id) + end end context 'using PostgreSQL' do @@ -189,6 +244,14 @@ describe Gitlab::Database::MigrationHelpers do column: :user_id, on_delete: :nullify) end + + it 'does not create a foreign key if it exists already' do + expect(model).to receive(:foreign_key_exists?).with(:projects, :users, column: :user_id).and_return(true) + expect(model).not_to receive(:execute).with(/ADD CONSTRAINT/) + expect(model).to receive(:execute).with(/VALIDATE CONSTRAINT/) + + model.add_concurrent_foreign_key(:projects, :users, column: :user_id) + end end end end @@ -203,6 +266,29 @@ describe Gitlab::Database::MigrationHelpers do end end + describe '#foreign_key_exists?' do + before do + key = ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(:projects, :users, { column: :non_standard_id }) + allow(model).to receive(:foreign_keys).with(:projects).and_return([key]) + end + + it 'finds existing foreign keys by column' do + expect(model.foreign_key_exists?(:projects, :users, column: :non_standard_id)).to be_truthy + end + + it 'finds existing foreign keys by target table only' do + expect(model.foreign_key_exists?(:projects, :users)).to be_truthy + end + + it 'compares by column name if given' do + expect(model.foreign_key_exists?(:projects, :users, column: :user_id)).to be_falsey + end + + it 'compares by target if no column given' do + expect(model.foreign_key_exists?(:projects, :other_table)).to be_falsey + end + end + describe '#disable_statement_timeout' do context 'using PostgreSQL' do it 'disables statement timeouts' do diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index b2f13fae73f..1fe1d3926ad 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -287,6 +287,29 @@ describe Gitlab::Database do end end + describe '.cached_column_exists?' do + it 'only retrieves data once' do + expect(ActiveRecord::Base.connection).to receive(:columns).once.and_call_original + + 2.times do + expect(described_class.cached_column_exists?(:projects, :id)).to be_truthy + expect(described_class.cached_column_exists?(:projects, :bogus_column)).to be_falsey + end + end + end + + describe '.cached_table_exists?' do + it 'only retrieves data once per table' do + expect(ActiveRecord::Base.connection).to receive(:table_exists?).with(:projects).once.and_call_original + expect(ActiveRecord::Base.connection).to receive(:table_exists?).with(:bogus_table_name).once.and_call_original + + 2.times do + expect(described_class.cached_table_exists?(:projects)).to be_truthy + expect(described_class.cached_table_exists?(:bogus_table_name)).to be_falsey + end + end + end + describe '#true_value' do it 'returns correct value for PostgreSQL' do expect(described_class).to receive(:postgresql?).and_return(true) diff --git a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb index a067c42b75b..f48ee8924e8 100644 --- a/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb +++ b/spec/lib/gitlab/diff/file_collection/merge_request_diff_spec.rb @@ -12,7 +12,7 @@ describe Gitlab::Diff::FileCollection::MergeRequestDiff do diff_files end - it 'does not files marked as undiffable in .gitattributes' do + it 'does not highlight files marked as undiffable in .gitattributes' do allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(false) expect_any_instance_of(Gitlab::Diff::File).not_to receive(:highlighted_diff_lines) diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index 9204ea37963..0c2e18c268a 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -455,5 +455,17 @@ describe Gitlab::Diff::File do expect(diff_file.size).to be_zero end end + + describe '#different_type?' do + it 'returns false' do + expect(diff_file).not_to be_different_type + end + end + + describe '#content_changed?' do + it 'returns false' do + expect(diff_file).not_to be_content_changed + end + end end end diff --git a/spec/lib/gitlab/exclusive_lease_spec.rb b/spec/lib/gitlab/exclusive_lease_spec.rb index 6193e177668..aed7d8d81ce 100644 --- a/spec/lib/gitlab/exclusive_lease_spec.rb +++ b/spec/lib/gitlab/exclusive_lease_spec.rb @@ -88,4 +88,16 @@ describe Gitlab::ExclusiveLease, :clean_gitlab_redis_shared_state do expect(lease.ttl).to be_nil end end + + describe '.reset_all!' do + it 'removes all existing lease keys from redis' do + uuid = described_class.new(unique_key, timeout: 3600).try_obtain + + expect(described_class.get_uuid(unique_key)).to eq(uuid) + + described_class.reset_all! + + expect(described_class.get_uuid(unique_key)).to be_falsey + end + end end diff --git a/spec/lib/gitlab/git/gitlab_projects_spec.rb b/spec/lib/gitlab/git/gitlab_projects_spec.rb index 45bcd730332..dfccc15a4f3 100644 --- a/spec/lib/gitlab/git/gitlab_projects_spec.rb +++ b/spec/lib/gitlab/git/gitlab_projects_spec.rb @@ -28,7 +28,7 @@ describe Gitlab::Git::GitlabProjects do describe '#push_branches' do let(:remote_name) { 'remote-name' } let(:branch_name) { 'master' } - let(:cmd) { %W(git push -- #{remote_name} #{branch_name}) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} push -- #{remote_name} #{branch_name}) } let(:force) { false } subject { gl_projects.push_branches(remote_name, 600, force, [branch_name]) } @@ -46,7 +46,7 @@ describe Gitlab::Git::GitlabProjects do end context 'with --force' do - let(:cmd) { %W(git push --force -- #{remote_name} #{branch_name}) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} push --force -- #{remote_name} #{branch_name}) } let(:force) { true } it 'executes the command' do @@ -65,7 +65,7 @@ describe Gitlab::Git::GitlabProjects do let(:tags) { true } let(:args) { { force: force, tags: tags, prune: prune }.merge(extra_args) } let(:extra_args) { {} } - let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --tags) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --tags) } subject { gl_projects.fetch_remote(remote_name, 600, args) } @@ -98,7 +98,7 @@ describe Gitlab::Git::GitlabProjects do context 'with --force' do let(:force) { true } - let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --force --tags) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --force --tags) } it 'executes the command with forced option' do stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) @@ -109,7 +109,7 @@ describe Gitlab::Git::GitlabProjects do context 'with --no-tags' do let(:tags) { false } - let(:cmd) { %W(git fetch #{remote_name} --quiet --prune --no-tags) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --prune --no-tags) } it 'executes the command' do stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) @@ -120,7 +120,7 @@ describe Gitlab::Git::GitlabProjects do context 'with no prune' do let(:prune) { false } - let(:cmd) { %W(git fetch #{remote_name} --quiet --tags) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} fetch #{remote_name} --quiet --tags) } it 'executes the command' do stub_spawn(cmd, 600, tmp_repo_path, {}, success: true) @@ -165,7 +165,7 @@ describe Gitlab::Git::GitlabProjects do describe '#import_project' do let(:project) { create(:project) } let(:import_url) { TestEnv.factory_repo_path_bare } - let(:cmd) { %W(git clone --bare -- #{import_url} #{tmp_repo_path}) } + let(:cmd) { %W(#{Gitlab.config.git.bin_path} clone --bare -- #{import_url} #{tmp_repo_path}) } let(:timeout) { 600 } subject { gl_projects.import_project(import_url, timeout) } diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 52c9876cbb6..54ada3e423f 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -681,7 +681,7 @@ describe Gitlab::Git::Repository, seed_helper: true do subject { new_repository.fetch_repository_as_mirror(repository) } before do - Gitlab::Shell.new.add_repository('default', 'my_project') + Gitlab::Shell.new.create_repository('default', 'my_project') end after do diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index bece82e531a..a204a8f1ffe 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -279,6 +279,7 @@ project: - lfs_file_locks - project_badges - source_of_merge_requests +- internal_ids award_emoji: - awardable - user diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index 62ef93f847a..4a51777ba9b 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -43,7 +43,6 @@ { "id": 40, "title": "Voluptatem", - "assignee_id": 1, "author_id": 22, "project_id": 5, "created_at": "2016-06-14T15:02:08.340Z", @@ -61,7 +60,23 @@ "issue_assignees": [ { "user_id": 1, - "issue_id": 1 + "issue_id": 40 + }, + { + "user_id": 15, + "issue_id": 40 + }, + { + "user_id": 16, + "issue_id": 40 + }, + { + "user_id": 16, + "issue_id": 40 + }, + { + "user_id": 6, + "issue_id": 40 } ], "milestone": { @@ -319,8 +334,7 @@ }, { "id": 39, - "title": "Delectus veniam ratione in eos culpa et natus molestiae earum aut.", - "assignee_id": 20, + "title": "Issue without assignees", "author_id": 22, "project_id": 5, "created_at": "2016-06-14T15:02:08.233Z", @@ -334,6 +348,7 @@ "confidential": false, "due_date": null, "moved_to_id": null, + "issue_assignees": [], "milestone": { "id": 1, "title": "test milestone", @@ -539,7 +554,6 @@ { "id": 38, "title": "Quasi adipisci non cupiditate dolorem quo qui earum sed.", - "assignee_id": 1, "author_id": 6, "project_id": 5, "created_at": "2016-06-14T15:02:08.154Z", @@ -756,7 +770,6 @@ { "id": 37, "title": "Cupiditate quo aut ducimus minima molestiae vero numquam possimus.", - "assignee_id": 15, "author_id": 20, "project_id": 5, "created_at": "2016-06-14T15:02:08.051Z", @@ -952,7 +965,6 @@ { "id": 36, "title": "Necessitatibus dolor est enim quia rem suscipit quidem voluptas ullam.", - "assignee_id": 20, "author_id": 16, "project_id": 5, "created_at": "2016-06-14T15:02:07.958Z", @@ -1148,7 +1160,6 @@ { "id": 35, "title": "Repellat praesentium deserunt maxime incidunt harum porro qui.", - "assignee_id": 6, "author_id": 20, "project_id": 5, "created_at": "2016-06-14T15:02:07.832Z", @@ -1344,7 +1355,6 @@ { "id": 34, "title": "Ullam expedita deserunt libero consequatur quia dolor harum perferendis facere quidem.", - "assignee_id": 20, "author_id": 1, "project_id": 5, "created_at": "2016-06-14T15:02:07.717Z", @@ -1540,7 +1550,6 @@ { "id": 33, "title": "Numquam accusamus eos iste exercitationem magni non inventore.", - "assignee_id": 15, "author_id": 26, "project_id": 5, "created_at": "2016-06-14T15:02:07.611Z", @@ -1736,7 +1745,6 @@ { "id": 32, "title": "Necessitatibus magnam qui at velit consequatur perspiciatis.", - "assignee_id": 22, "author_id": 15, "project_id": 5, "created_at": "2016-06-14T15:02:07.431Z", @@ -1932,7 +1940,6 @@ { "id": 31, "title": "Libero nam magnam incidunt eaque placeat error et.", - "assignee_id": 1, "author_id": 16, "project_id": 5, "created_at": "2016-06-14T15:02:07.280Z", diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index f4e466d1296..8e25cd26c2f 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -4,7 +4,12 @@ include ImportExport::CommonUtil describe Gitlab::ImportExport::ProjectTreeRestorer do describe 'restore project tree' do before(:context) do - @user = create(:user) + # Using an admin for import, so we can check assignment of existing members + @user = create(:admin) + @existing_members = [ + create(:user, username: 'bernard_willms'), + create(:user, username: 'saul_will') + ] RSpec::Mocks.with_temporary_scope do @project = create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') @@ -37,6 +42,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(project.project_feature.merge_requests_access_level).to eq(ProjectFeature::ENABLED) end + it 'has the project description' do + expect(Project.find_by_path('project').description).to eq('Nisi et repellendus ut enim quo accusamus vel magnam.') + end + it 'has the project html description' do expect(Project.find_by_path('project').description_html).to eq('description') end @@ -63,8 +72,9 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(issue.reload.updated_at.to_s).to eq('2016-06-14 15:02:47 UTC') end - it 'has issue assignees' do - expect(Issue.where(title: 'Voluptatem').first.issue_assignees).not_to be_empty + it 'has multiple issue assignees' do + expect(Issue.find_by(title: 'Voluptatem').assignees).to contain_exactly(@user, *@existing_members) + expect(Issue.find_by(title: 'Issue without assignees').assignees).to be_empty end it 'contains the merge access levels on a protected branch' do diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 3049491f0ae..0d20a551e2a 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -29,8 +29,17 @@ describe Gitlab::ImportExport::ProjectTreeSaver do project_json(project_tree_saver.full_path) end + context 'with description override' do + let(:params) { { description: 'Foo Bar' } } + let(:project_tree_saver) { described_class.new(project: project, current_user: user, shared: shared, params: params) } + + it 'overrides the project description' do + expect(saved_project_json).to include({ 'description' => params[:description] }) + end + end + it 'saves the correct json' do - expect(saved_project_json).to include({ "visibility_level" => 20 }) + expect(saved_project_json).to include({ 'description' => 'description', 'visibility_level' => 20 }) end it 'has milestones' do @@ -259,6 +268,7 @@ describe Gitlab::ImportExport::ProjectTreeSaver do :issues_disabled, :wiki_enabled, :builds_private, + description: 'description', issues: [issue], snippets: [snippet], releases: [release], diff --git a/spec/lib/gitlab/kubernetes/namespace_spec.rb b/spec/lib/gitlab/kubernetes/namespace_spec.rb index b3c987f9344..e098612f6fb 100644 --- a/spec/lib/gitlab/kubernetes/namespace_spec.rb +++ b/spec/lib/gitlab/kubernetes/namespace_spec.rb @@ -9,7 +9,7 @@ describe Gitlab::Kubernetes::Namespace do describe '#exists?' do context 'when namespace do not exits' do - let(:exception) { ::KubeException.new(404, "namespace #{name} not found", nil) } + let(:exception) { ::Kubeclient::HttpError.new(404, "namespace #{name} not found", nil) } it 'returns false' do expect(client).to receive(:get_namespace).with(name).once.and_raise(exception) diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb new file mode 100644 index 00000000000..d808b4d49e0 --- /dev/null +++ b/spec/lib/gitlab/omniauth_initializer_spec.rb @@ -0,0 +1,65 @@ +require 'spec_helper' + +describe Gitlab::OmniauthInitializer do + let(:devise_config) { class_double(Devise) } + + subject { described_class.new(devise_config) } + + describe '#execute' do + it 'configures providers from array' do + generic_config = { 'name' => 'generic' } + + expect(devise_config).to receive(:omniauth).with(:generic) + + subject.execute([generic_config]) + end + + it 'allows "args" array for app_id and app_secret' do + legacy_config = { 'name' => 'legacy', 'args' => %w(123 abc) } + + expect(devise_config).to receive(:omniauth).with(:legacy, '123', 'abc') + + subject.execute([legacy_config]) + end + + it 'passes app_id and app_secret as additional arguments' do + twitter_config = { 'name' => 'twitter', 'app_id' => '123', 'app_secret' => 'abc' } + + expect(devise_config).to receive(:omniauth).with(:twitter, '123', 'abc') + + subject.execute([twitter_config]) + end + + it 'passes "args" hash as symbolized hash argument' do + hash_config = { 'name' => 'hash', 'args' => { 'custom' => 'format' } } + + expect(devise_config).to receive(:omniauth).with(:hash, custom: 'format') + + subject.execute([hash_config]) + end + + it 'configures fail_with_empty_uid for shibboleth' do + shibboleth_config = { 'name' => 'shibboleth', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:shibboleth, fail_with_empty_uid: true) + + subject.execute([shibboleth_config]) + end + + it 'configures remote_sign_out_handler proc for authentiq' do + authentiq_config = { 'name' => 'authentiq', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:authentiq, remote_sign_out_handler: an_instance_of(Proc)) + + subject.execute([authentiq_config]) + end + + it 'configures on_single_sign_out proc for cas3' do + cas3_config = { 'name' => 'cas3', 'args' => {} } + + expect(devise_config).to receive(:omniauth).with(:cas3, on_single_sign_out: an_instance_of(Proc)) + + subject.execute([cas3_config]) + end + end +end diff --git a/spec/lib/gitlab/profiler_spec.rb b/spec/lib/gitlab/profiler_spec.rb index f02b1cf55fb..548eb28fe4d 100644 --- a/spec/lib/gitlab/profiler_spec.rb +++ b/spec/lib/gitlab/profiler_spec.rb @@ -94,10 +94,12 @@ describe Gitlab::Profiler do it 'strips out the private token' do expect(custom_logger).to receive(:add) do |severity, _progname, message| + next if message.include?('spec/') + expect(severity).to eq(Logger::DEBUG) expect(message).to include('public').and include(described_class::FILTERED_STRING) expect(message).not_to include(private_token) - end + end.twice custom_logger.debug("public #{private_token}") end @@ -108,8 +110,8 @@ describe Gitlab::Profiler do custom_logger.debug('User Load (1.3ms)') custom_logger.debug('Project Load (10.4ms)') - expect(custom_logger.load_times_by_model).to eq('User' => 2.5, - 'Project' => 10.4) + expect(custom_logger.load_times_by_model).to eq('User' => [1.2, 1.3], + 'Project' => [10.4]) end it 'logs the backtrace, ignoring lines as appropriate' do @@ -162,4 +164,24 @@ describe Gitlab::Profiler do end end end + + describe '.log_load_times_by_model' do + it 'logs the model, query count, and time by slowest first' do + expect(null_logger).to receive(:load_times_by_model).and_return( + 'User' => [1.2, 1.3], + 'Project' => [10.4] + ) + + expect(null_logger).to receive(:info).with('Project total (1): 10.4ms') + expect(null_logger).to receive(:info).with('User total (2): 2.5ms') + + described_class.log_load_times_by_model(null_logger) + end + + it 'does nothing when called with a logger that does not have load times' do + expect(null_logger).not_to receive(:info) + + expect(described_class.log_load_times_by_model(null_logger)).to be_nil + end + end end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index c46bb8edebf..8351b967133 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -83,19 +83,19 @@ describe Gitlab::ProjectSearchResults do end context 'when the matching filename contains a colon' do - let(:search_result) { "\nmaster:testdata/project::function1.yaml\x001\x00---\n" } + let(:search_result) { "master:testdata/project::function1.yaml\x001\x00---\n" } it 'returns a valid FoundBlob' do expect(subject.filename).to eq('testdata/project::function1.yaml') expect(subject.basename).to eq('testdata/project::function1') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq('---') + expect(subject.data).to eq("---\n") end end context 'when the matching content contains a number surrounded by colons' do - let(:search_result) { "\nmaster:testdata/foo.txt\x001\x00blah:9:blah" } + let(:search_result) { "master:testdata/foo.txt\x001\x00blah:9:blah" } it 'returns a valid FoundBlob' do expect(subject.filename).to eq('testdata/foo.txt') @@ -106,16 +106,40 @@ describe Gitlab::ProjectSearchResults do end end + context 'when the search result ends with an empty line' do + let(:results) { project.repository.search_files_by_content('Role models', 'master') } + + it 'returns a valid FoundBlob that ends with an empty line' do + expect(subject.filename).to eq('files/markdown/ruby-style-guide.md') + expect(subject.basename).to eq('files/markdown/ruby-style-guide') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("# Prelude\n\n> Role models are important. <br/>\n> -- Officer Alex J. Murphy / RoboCop\n\n") + end + end + context 'when the search returns non-ASCII data' do context 'with UTF-8' do - let(:results) { project.repository.search_files_by_content("файл", 'master') } + let(:results) { project.repository.search_files_by_content('файл', 'master') } it 'returns results as UTF-8' do expect(subject.filename).to eq('encoding/russian.rb') expect(subject.basename).to eq('encoding/russian') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq("Хороший файл") + expect(subject.data).to eq("Хороший файл\n") + end + end + + context 'with UTF-8 in the filename' do + let(:results) { project.repository.search_files_by_content('webhook', 'master') } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/テスト.txt') + expect(subject.basename).to eq('encoding/テスト') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(3) + expect(subject.data).to include('WebHookの確認') end end @@ -127,7 +151,7 @@ describe Gitlab::ProjectSearchResults do expect(subject.basename).to eq('encoding/iso8859') expect(subject.ref).to eq('master') expect(subject.startline).to eq(1) - expect(subject.data).to eq("Äü\n\nfoo") + expect(subject.data).to eq("Äü\n\nfoo\n") end end end diff --git a/spec/lib/gitlab/project_transfer_spec.rb b/spec/lib/gitlab/project_transfer_spec.rb index 10c5fb148cd..0b9b1f537b5 100644 --- a/spec/lib/gitlab/project_transfer_spec.rb +++ b/spec/lib/gitlab/project_transfer_spec.rb @@ -21,30 +21,77 @@ describe Gitlab::ProjectTransfer do describe '#move_project' do it "moves project upload to another namespace" do - FileUtils.mkdir_p(File.join(@root_dir, @namespace_path_was, @project_path)) + path_to_be_moved = File.join(@root_dir, @namespace_path_was, @project_path) + expected_path = File.join(@root_dir, @namespace_path, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + @project_transfer.move_project(@project_path, @namespace_path_was, @namespace_path) - expected_path = File.join(@root_dir, @namespace_path, @project_path) expect(Dir.exist?(expected_path)).to be_truthy end end + describe '#move_namespace' do + context 'when moving namespace from root into another namespace' do + it "moves namespace projects' upload" do + child_namespace = 'test_child_namespace' + path_to_be_moved = File.join(@root_dir, child_namespace, @project_path) + expected_path = File.join(@root_dir, @namespace_path, child_namespace, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + + @project_transfer.move_namespace(child_namespace, nil, @namespace_path) + + expect(Dir.exist?(expected_path)).to be_truthy + end + end + + context 'when moving namespace from one parent to another' do + it "moves namespace projects' upload" do + child_namespace = 'test_child_namespace' + path_to_be_moved = File.join(@root_dir, @namespace_path_was, child_namespace, @project_path) + expected_path = File.join(@root_dir, @namespace_path, child_namespace, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + + @project_transfer.move_namespace(child_namespace, @namespace_path_was, @namespace_path) + + expect(Dir.exist?(expected_path)).to be_truthy + end + end + + context 'when moving namespace from having a parent to root' do + it "moves namespace projects' upload" do + child_namespace = 'test_child_namespace' + path_to_be_moved = File.join(@root_dir, @namespace_path_was, child_namespace, @project_path) + expected_path = File.join(@root_dir, child_namespace, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + + @project_transfer.move_namespace(child_namespace, @namespace_path_was, nil) + + expect(Dir.exist?(expected_path)).to be_truthy + end + end + end + describe '#rename_project' do it "renames project" do - FileUtils.mkdir_p(File.join(@root_dir, @namespace_path, @project_path_was)) + path_to_be_moved = File.join(@root_dir, @namespace_path, @project_path_was) + expected_path = File.join(@root_dir, @namespace_path, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + @project_transfer.rename_project(@project_path_was, @project_path, @namespace_path) - expected_path = File.join(@root_dir, @namespace_path, @project_path) expect(Dir.exist?(expected_path)).to be_truthy end end describe '#rename_namespace' do it "renames namespace" do - FileUtils.mkdir_p(File.join(@root_dir, @namespace_path_was, @project_path)) + path_to_be_moved = File.join(@root_dir, @namespace_path_was, @project_path) + expected_path = File.join(@root_dir, @namespace_path, @project_path) + FileUtils.mkdir_p(path_to_be_moved) + @project_transfer.rename_namespace(@namespace_path_was, @namespace_path) - expected_path = File.join(@root_dir, @namespace_path, @project_path) expect(Dir.exist?(expected_path)).to be_truthy end end diff --git a/spec/lib/gitlab/repository_cache_adapter_spec.rb b/spec/lib/gitlab/repository_cache_adapter_spec.rb new file mode 100644 index 00000000000..85971f2a7ef --- /dev/null +++ b/spec/lib/gitlab/repository_cache_adapter_spec.rb @@ -0,0 +1,76 @@ +require 'spec_helper' + +describe Gitlab::RepositoryCacheAdapter do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:cache) { repository.send(:cache) } + + describe '#cache_method_output', :use_clean_rails_memory_store_caching do + let(:fallback) { 10 } + + context 'with a non-existing repository' do + let(:project) { create(:project) } # No repository + + subject do + repository.cache_method_output(:cats, fallback: fallback) do + repository.cats_call_stub + end + end + + it 'returns the fallback value' do + expect(subject).to eq(fallback) + end + + it 'avoids calling the original method' do + expect(repository).not_to receive(:cats_call_stub) + + subject + end + end + + context 'with a method throwing a non-existing-repository error' do + subject do + repository.cache_method_output(:cats, fallback: fallback) do + raise Gitlab::Git::Repository::NoRepository + end + end + + it 'returns the fallback value' do + expect(subject).to eq(fallback) + end + + it 'does not cache the data' do + subject + + expect(repository.instance_variable_defined?(:@cats)).to eq(false) + expect(cache.exist?(:cats)).to eq(false) + end + end + + context 'with an existing repository' do + it 'caches the output' do + object = double + + expect(object).to receive(:number).once.and_return(10) + + 2.times do + val = repository.cache_method_output(:cats) { object.number } + + expect(val).to eq(10) + end + + expect(repository.send(:cache).exist?(:cats)).to eq(true) + expect(repository.instance_variable_get(:@cats)).to eq(10) + end + end + end + + describe '#expire_method_caches' do + it 'expires the caches of the given methods' do + expect(cache).to receive(:expire).with(:readme) + expect(cache).to receive(:expire).with(:gitignore) + + repository.expire_method_caches(%i(readme gitignore)) + end + end +end diff --git a/spec/lib/gitlab/repository_cache_spec.rb b/spec/lib/gitlab/repository_cache_spec.rb new file mode 100644 index 00000000000..fc259cf1208 --- /dev/null +++ b/spec/lib/gitlab/repository_cache_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe Gitlab::RepositoryCache do + let(:backend) { double('backend').as_null_object } + let(:project) { create(:project) } + let(:repository) { project.repository } + let(:namespace) { "#{repository.full_path}:#{project.id}" } + let(:cache) { described_class.new(repository, backend: backend) } + + describe '#cache_key' do + subject { cache.cache_key(:foo) } + + it 'includes the namespace' do + expect(subject).to eq "foo:#{namespace}" + end + + context 'with a given namespace' do + let(:extra_namespace) { 'my:data' } + let(:cache) do + described_class.new(repository, extra_namespace: extra_namespace, + backend: backend) + end + + it 'includes the full namespace' do + expect(subject).to eq "foo:#{namespace}:#{extra_namespace}" + end + end + end + + describe '#expire' do + it 'expires the given key from the cache' do + cache.expire(:foo) + expect(backend).to have_received(:delete).with("foo:#{namespace}") + end + end + + describe '#fetch' do + it 'fetches the given key from the cache' do + cache.fetch(:bar) + expect(backend).to have_received(:fetch).with("bar:#{namespace}") + end + + it 'accepts a block' do + p = -> {} + + cache.fetch(:baz, &p) + expect(backend).to have_received(:fetch).with("baz:#{namespace}", &p) + end + end +end diff --git a/spec/lib/gitlab/shell_spec.rb b/spec/lib/gitlab/shell_spec.rb index 56b45d8da3c..14b59c5e945 100644 --- a/spec/lib/gitlab/shell_spec.rb +++ b/spec/lib/gitlab/shell_spec.rb @@ -20,7 +20,7 @@ describe Gitlab::Shell do it { is_expected.to respond_to :add_key } it { is_expected.to respond_to :remove_key } - it { is_expected.to respond_to :add_repository } + it { is_expected.to respond_to :create_repository } it { is_expected.to respond_to :remove_repository } it { is_expected.to respond_to :fork_repository } @@ -402,8 +402,8 @@ describe Gitlab::Shell do allow(Gitlab.config.gitlab_shell).to receive(:git_timeout).and_return(800) end - describe '#add_repository' do - shared_examples '#add_repository' do + describe '#create_repository' do + shared_examples '#create_repository' do let(:repository_storage) { 'default' } let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } let(:repo_name) { 'project/path' } @@ -414,7 +414,7 @@ describe Gitlab::Shell do end it 'creates a repository' do - expect(gitlab_shell.add_repository(repository_storage, repo_name)).to be_truthy + expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_truthy expect(File.stat(created_path).mode & 0o777).to eq(0o770) @@ -426,19 +426,19 @@ describe Gitlab::Shell do it 'returns false when the command fails' do FileUtils.mkdir_p(File.dirname(created_path)) # This file will block the creation of the repo's .git directory. That - # should cause #add_repository to fail. + # should cause #create_repository to fail. FileUtils.touch(created_path) - expect(gitlab_shell.add_repository(repository_storage, repo_name)).to be_falsy + expect(gitlab_shell.create_repository(repository_storage, repo_name)).to be_falsy end end context 'with gitaly' do - it_behaves_like '#add_repository' + it_behaves_like '#create_repository' end context 'without gitaly', :skip_gitaly_mock do - it_behaves_like '#add_repository' + it_behaves_like '#create_repository' end end diff --git a/spec/lib/gitlab/slash_commands/command_spec.rb b/spec/lib/gitlab/slash_commands/command_spec.rb index e3447d974aa..194cae8c645 100644 --- a/spec/lib/gitlab/slash_commands/command_spec.rb +++ b/spec/lib/gitlab/slash_commands/command_spec.rb @@ -108,5 +108,10 @@ describe Gitlab::SlashCommands::Command do it { is_expected.to eq(Gitlab::SlashCommands::IssueSearch) } end + + context 'IssueMove is triggered' do + let(:params) { { text: 'issue move #78291 to gitlab/gitlab-ci' } } + it { is_expected.to eq(Gitlab::SlashCommands::IssueMove) } + end end end diff --git a/spec/lib/gitlab/slash_commands/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/issue_move_spec.rb new file mode 100644 index 00000000000..d41441c9472 --- /dev/null +++ b/spec/lib/gitlab/slash_commands/issue_move_spec.rb @@ -0,0 +1,117 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::IssueMove, service: true do + describe '#match' do + shared_examples_for 'move command' do |text_command| + it 'can be parsed to extract the needed fields' do + match_data = described_class.match(text_command) + + expect(match_data['iid']).to eq('123456') + expect(match_data['project_path']).to eq('gitlab/gitlab-ci') + end + end + + it_behaves_like 'move command', 'issue move #123456 to gitlab/gitlab-ci' + it_behaves_like 'move command', 'issue move #123456 gitlab/gitlab-ci' + it_behaves_like 'move command', 'issue move #123456 gitlab/gitlab-ci ' + it_behaves_like 'move command', 'issue move 123456 to gitlab/gitlab-ci' + it_behaves_like 'move command', 'issue move 123456 gitlab/gitlab-ci' + it_behaves_like 'move command', 'issue move 123456 gitlab/gitlab-ci ' + end + + describe '#execute' do + set(:user) { create(:user) } + set(:issue) { create(:issue) } + set(:chat_name) { create(:chat_name, user: user) } + set(:project) { issue.project } + set(:other_project) { create(:project, namespace: project.namespace) } + + before do + [project, other_project].each { |prj| prj.add_master(user) } + end + + subject { described_class.new(project, chat_name) } + + def process_message(message) + subject.execute(described_class.match(message)) + end + + context 'when the user can move the issue' do + context 'when the move fails' do + it 'returns the error message' do + message = "issue move #{issue.iid} #{project.full_path}" + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('Cannot move issue')) + end + end + + context 'when the move succeeds' do + let(:message) { "issue move #{issue.iid} #{other_project.full_path}" } + + it 'moves the issue to the new destination' do + expect { process_message(message) }.to change { Issue.count }.by(1) + + new_issue = issue.reload.moved_to + + expect(new_issue.state).to eq('opened') + expect(new_issue.project_id).to eq(other_project.id) + expect(new_issue.author_id).to eq(issue.author_id) + + expect(issue.state).to eq('closed') + expect(issue.project_id).to eq(project.id) + end + + it 'returns the new issue' do + expect(process_message(message)) + .to include(response_type: :in_channel, + attachments: [a_hash_including(title_link: a_string_including(other_project.full_path))]) + end + + it 'mentions the old issue' do + expect(process_message(message)) + .to include(attachments: [a_hash_including(pretext: a_string_including(project.full_path))]) + end + end + end + + context 'when the issue does not exist' do + it 'returns not found' do + message = "issue move #{issue.iid.succ} #{other_project.full_path}" + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('not found')) + end + end + + context 'when the target project does not exist' do + it 'returns not found' do + message = "issue move #{issue.iid} #{other_project.full_path}/foo" + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('not found')) + end + end + + context 'when the user cannot see the target project' do + it 'returns not found' do + message = "issue move #{issue.iid} #{other_project.full_path}" + other_project.team.truncate + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('not found')) + end + end + + context 'when the user does not have the required permissions on the target project' do + it 'returns the error message' do + message = "issue move #{issue.iid} #{other_project.full_path}" + other_project.team.truncate + other_project.team.add_guest(user) + + expect(process_message(message)).to include(response_type: :ephemeral, + text: a_string_matching('Cannot move issue')) + end + end + end +end diff --git a/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb b/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb new file mode 100644 index 00000000000..58c341a284e --- /dev/null +++ b/spec/lib/gitlab/slash_commands/presenters/issue_move_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Gitlab::SlashCommands::Presenters::IssueMove do + set(:admin) { create(:admin) } + set(:project) { create(:project) } + set(:other_project) { create(:project) } + set(:old_issue) { create(:issue, project: project) } + set(:new_issue) { Issues::MoveService.new(project, admin).execute(old_issue, other_project) } + let(:attachment) { subject[:attachments].first } + + subject { described_class.new(new_issue).present(old_issue) } + + it { is_expected.to be_a(Hash) } + + it 'shows the new issue' do + expect(subject[:response_type]).to be(:in_channel) + expect(subject).to have_key(:attachments) + expect(attachment[:title]).to start_with(new_issue.title) + expect(attachment[:title_link]).to include(other_project.full_path) + end + + it 'mentions the old issue and the new issue in the pretext' do + expect(attachment[:pretext]).to include(project.full_path) + expect(attachment[:pretext]).to include(other_project.full_path) + end +end diff --git a/spec/lib/gitlab/verify/job_artifacts_spec.rb b/spec/lib/gitlab/verify/job_artifacts_spec.rb new file mode 100644 index 00000000000..ec490bdfde2 --- /dev/null +++ b/spec/lib/gitlab/verify/job_artifacts_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Gitlab::Verify::JobArtifacts do + include GitlabVerifyHelpers + + it_behaves_like 'Gitlab::Verify::BatchVerifier subclass' do + let!(:objects) { create_list(:ci_job_artifact, 3, :archive) } + end + + describe '#run_batches' do + let(:failures) { collect_failures } + let(:failure) { failures[artifact] } + + let!(:artifact) { create(:ci_job_artifact, :archive, :correct_checksum) } + + it 'passes artifacts with the correct file' do + expect(failures).to eq({}) + end + + it 'fails artifacts with a missing file' do + FileUtils.rm_f(artifact.file.path) + + expect(failures.keys).to contain_exactly(artifact) + expect(failure).to be_a(Errno::ENOENT) + expect(failure.to_s).to include(artifact.file.path) + end + + it 'fails artifacts with a mismatched checksum' do + File.truncate(artifact.file.path, 0) + + expect(failures.keys).to contain_exactly(artifact) + expect(failure.to_s).to include('Checksum mismatch') + end + end +end diff --git a/spec/lib/repository_cache_spec.rb b/spec/lib/repository_cache_spec.rb deleted file mode 100644 index 8b0c7254b5e..00000000000 --- a/spec/lib/repository_cache_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -require 'spec_helper' - -describe RepositoryCache do - let(:project) { create(:project) } - let(:backend) { double('backend').as_null_object } - let(:cache) { described_class.new('example', project.id, backend) } - - describe '#cache_key' do - it 'includes the namespace' do - expect(cache.cache_key(:foo)).to eq "foo:example:#{project.id}" - end - end - - describe '#expire' do - it 'expires the given key from the cache' do - cache.expire(:foo) - expect(backend).to have_received(:delete).with("foo:example:#{project.id}") - end - end - - describe '#fetch' do - it 'fetches the given key from the cache' do - cache.fetch(:bar) - expect(backend).to have_received(:fetch).with("bar:example:#{project.id}") - end - - it 'accepts a block' do - p = -> {} - - cache.fetch(:baz, &p) - expect(backend).to have_received(:fetch).with("baz:example:#{project.id}", &p) - end - end -end diff --git a/spec/migrations/migrate_old_artifacts_spec.rb b/spec/migrations/migrate_old_artifacts_spec.rb index 92eb1d9ce86..638b2853374 100644 --- a/spec/migrations/migrate_old_artifacts_spec.rb +++ b/spec/migrations/migrate_old_artifacts_spec.rb @@ -66,7 +66,7 @@ describe MigrateOldArtifacts do end it 'all files do have artifacts' do - Ci::Build.with_artifacts do |build| + Ci::Build.with_artifacts_archive do |build| expect(build).to have_artifacts end end diff --git a/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb b/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb new file mode 100644 index 00000000000..26489ef58bd --- /dev/null +++ b/spec/migrations/reschedule_commits_count_for_merge_request_diff_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' +require Rails.root.join('db', 'migrate', '20180309121820_reschedule_commits_count_for_merge_request_diff') + +describe RescheduleCommitsCountForMergeRequestDiff, :migration, :sidekiq do + let(:merge_request_diffs) { table(:merge_request_diffs) } + let(:merge_requests) { table(:merge_requests) } + let(:projects) { table(:projects) } + let(:namespaces) { table(:namespaces) } + + before do + stub_const("#{described_class.name}::BATCH_SIZE", 1) + + namespaces.create!(id: 1, name: 'gitlab', path: 'gitlab') + + projects.create!(id: 1, namespace_id: 1) + + merge_requests.create!(id: 1, target_project_id: 1, source_project_id: 1, target_branch: 'feature', source_branch: 'master') + + merge_request_diffs.create!(id: 1, merge_request_id: 1) + merge_request_diffs.create!(id: 2, merge_request_id: 1) + merge_request_diffs.create!(id: 3, merge_request_id: 1, commits_count: 0) + merge_request_diffs.create!(id: 4, merge_request_id: 1) + end + + it 'correctly schedules background migrations' do + Sidekiq::Testing.fake! do + Timecop.freeze do + migrate! + + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(5.minutes, 1, 1) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(10.minutes, 2, 2) + expect(described_class::MIGRATION).to be_scheduled_delayed_migration(15.minutes, 4, 4) + expect(BackgroundMigrationWorker.jobs.size).to eq 3 + end + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 5e03ada4987..7d935cf8d76 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -80,6 +80,42 @@ describe Ci::Build do end end + describe '.with_artifacts_archive' do + subject { described_class.with_artifacts_archive } + + context 'when job does not have an archive' do + let!(:job) { create(:ci_build) } + + it 'does not return the job' do + is_expected.not_to include(job) + end + end + + context 'when job has a legacy archive' do + let!(:job) { create(:ci_build, :legacy_artifacts) } + + it 'returns the job' do + is_expected.to include(job) + end + end + + context 'when job has a job artifact archive' do + let!(:job) { create(:ci_build, :artifacts) } + + it 'returns the job' do + is_expected.to include(job) + end + end + + context 'when job has a job artifact trace' do + let!(:job) { create(:ci_build, :trace_artifact) } + + it 'does not return the job' do + is_expected.not_to include(job) + end + end + end + describe '#actionize' do context 'when build is a created' do before do @@ -701,21 +737,21 @@ describe Ci::Build do describe '#erase' do before do - build.erase(erased_by: user) + build.erase(erased_by: erased_by) end context 'erased by user' do - let!(:user) { create(:user, username: 'eraser') } + let!(:erased_by) { create(:user, username: 'eraser') } include_examples 'erasable' it 'records user who erased a build' do - expect(build.erased_by).to eq user + expect(build.erased_by).to eq erased_by end end context 'erased by system' do - let(:user) { nil } + let(:erased_by) { nil } include_examples 'erasable' @@ -770,21 +806,21 @@ describe Ci::Build do describe '#erase' do before do - build.erase(erased_by: user) + build.erase(erased_by: erased_by) end context 'erased by user' do - let!(:user) { create(:user, username: 'eraser') } + let!(:erased_by) { create(:user, username: 'eraser') } include_examples 'erasable' it 'records user who erased a build' do - expect(build.erased_by).to eq user + expect(build.erased_by).to eq erased_by end end context 'erased by system' do - let(:user) { nil } + let(:erased_by) { nil } include_examples 'erasable' @@ -1446,6 +1482,17 @@ describe Ci::Build do { key: 'CI_COMMIT_SHA', value: build.sha, public: true }, { key: 'CI_COMMIT_REF_NAME', value: build.ref, public: true }, { key: 'CI_COMMIT_REF_SLUG', value: build.ref_slug, public: true }, + { key: 'CI_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, + { key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false }, + { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false }, + { key: 'CI_BUILD_ID', value: build.id.to_s, public: true }, + { key: 'CI_BUILD_TOKEN', value: build.token, public: false }, + { key: 'CI_BUILD_REF', value: build.sha, public: true }, + { key: 'CI_BUILD_BEFORE_SHA', value: build.before_sha, public: true }, + { key: 'CI_BUILD_REF_NAME', value: build.ref, public: true }, + { key: 'CI_BUILD_REF_SLUG', value: build.ref_slug, public: true }, + { key: 'CI_BUILD_NAME', value: 'test', public: true }, + { key: 'CI_BUILD_STAGE', value: 'test', public: true }, { key: 'CI_PROJECT_ID', value: project.id.to_s, public: true }, { key: 'CI_PROJECT_NAME', value: project.path, public: true }, { key: 'CI_PROJECT_PATH', value: project.full_path, public: true }, @@ -1455,9 +1502,7 @@ 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_REGISTRY_USER', value: 'gitlab-ci-token', public: true }, - { key: 'CI_REGISTRY_PASSWORD', value: build.token, public: false }, - { key: 'CI_REPOSITORY_URL', value: build.repo_url, public: false } + { key: 'CI_PIPELINE_SOURCE', value: pipeline.source, public: true } ] end @@ -1856,39 +1901,6 @@ describe Ci::Build do it { is_expected.to include(ci_config_path) } end - context 'returns variables in valid order' do - let(:build_pre_var) { { key: 'build', value: 'value' } } - let(:project_pre_var) { { key: 'project', value: 'value' } } - let(:pipeline_pre_var) { { key: 'pipeline', value: 'value' } } - let(:build_yaml_var) { { key: 'yaml', value: 'value' } } - - before do - allow(build).to receive(:predefined_variables) { [build_pre_var] } - allow(build).to receive(:yaml_variables) { [build_yaml_var] } - - allow_any_instance_of(Project) - .to receive(:predefined_variables) { [project_pre_var] } - - allow_any_instance_of(Project) - .to receive(:secret_variables_for) - .with(ref: 'master', environment: nil) do - [create(:ci_variable, key: 'secret', value: 'value')] - end - - allow_any_instance_of(Ci::Pipeline) - .to receive(:predefined_variables) { [pipeline_pre_var] } - end - - it do - is_expected.to eq( - [build_pre_var, - project_pre_var, - pipeline_pre_var, - build_yaml_var, - { key: 'secret', value: 'value', public: false }]) - end - end - context 'when using auto devops' do context 'and is enabled' do before do @@ -1912,6 +1924,81 @@ describe Ci::Build do end end end + + context 'when pipeline variable overrides build variable' do + before do + build.yaml_variables = [{ key: 'MYVAR', value: 'myvar', public: true }] + pipeline.variables.build(key: 'MYVAR', value: 'pipeline value') + end + + it 'overrides YAML variable using a pipeline variable' do + variables = subject.reverse.uniq { |variable| variable[:key] }.reverse + + expect(variables) + .not_to include(key: 'MYVAR', value: 'myvar', public: true) + expect(variables) + .to include(key: 'MYVAR', value: 'pipeline value', public: false) + end + end + + describe 'variables ordering' do + context 'when variables hierarchy is stubbed' do + let(:build_pre_var) { { key: 'build', value: 'value', public: true } } + let(:project_pre_var) { { key: 'project', value: 'value', public: true } } + let(:pipeline_pre_var) { { key: 'pipeline', value: 'value', public: true } } + let(:build_yaml_var) { { key: 'yaml', value: 'value', public: true } } + + before do + allow(build).to receive(:predefined_variables) { [build_pre_var] } + allow(build).to receive(:yaml_variables) { [build_yaml_var] } + + allow_any_instance_of(Project) + .to receive(:predefined_variables) { [project_pre_var] } + + allow_any_instance_of(Project) + .to receive(:secret_variables_for) + .with(ref: 'master', environment: nil) do + [create(:ci_variable, key: 'secret', value: 'value')] + end + + allow_any_instance_of(Ci::Pipeline) + .to receive(:predefined_variables) { [pipeline_pre_var] } + end + + it 'returns variables in order depending on resource hierarchy' do + is_expected.to eq( + [build_pre_var, + project_pre_var, + pipeline_pre_var, + build_yaml_var, + { key: 'secret', value: 'value', public: false }]) + end + end + + context 'when build has environment and user-provided variables' do + let(:expected_variables) do + predefined_variables.map { |variable| variable.fetch(:key) } + + %w[YAML_VARIABLE CI_ENVIRONMENT_NAME CI_ENVIRONMENT_SLUG + CI_ENVIRONMENT_URL] + end + + before do + create(:environment, project: build.project, + name: 'staging') + + build.yaml_variables = [{ key: 'YAML_VARIABLE', + value: 'var', + public: true }] + build.environment = 'staging' + end + + it 'matches explicit variables ordering' do + received_variables = subject.map { |variable| variable.fetch(:key) } + + expect(received_variables).to eq expected_variables + end + end + end end describe 'state transition: any => [:pending]' do @@ -1929,7 +2016,7 @@ describe Ci::Build do context 'when depended job has not been completed yet' do let!(:pre_stage_job) { create(:ci_build, :manual, pipeline: pipeline, name: 'test', stage_idx: 0) } - it { expect { job.run! }.not_to raise_error(Ci::Build::MissingDependenciesError) } + it { expect { job.run! }.not_to raise_error } end context 'when artifacts of depended job has been expired' do @@ -2036,6 +2123,35 @@ describe Ci::Build do subject.drop! end + + context 'when retry service raises Gitlab::Access::AccessDeniedError exception' do + let(:retry_service) { Ci::RetryBuildService.new(subject.project, subject.user) } + + before do + allow_any_instance_of(Ci::RetryBuildService) + .to receive(:execute) + .with(subject) + .and_raise(Gitlab::Access::AccessDeniedError) + allow(Rails.logger).to receive(:error) + end + + it 'handles raised exception' do + expect { subject.drop! }.not_to raise_exception(Gitlab::Access::AccessDeniedError) + end + + it 'logs the error' do + subject.drop! + + expect(Rails.logger) + .to have_received(:error) + .with(a_string_matching("Unable to auto-retry job #{subject.id}")) + end + + it 'fails the job' do + subject.drop! + expect(subject.failed?).to be_truthy + end + end end context 'when build is not configured to be retried' do diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 14d234f6aab..4635f8cfe9d 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -170,12 +170,10 @@ describe Ci::Pipeline, :mailer do describe '#predefined_variables' do subject { pipeline.predefined_variables } - it { is_expected.to be_an(Array) } - - it 'includes the defined keys' do - keys = subject.map { |v| v[:key] } + it 'includes all predefined variables in a valid order' do + keys = subject.map { |variable| variable[:key] } - expect(keys).to include('CI_PIPELINE_ID', 'CI_CONFIG_PATH', 'CI_PIPELINE_SOURCE') + expect(keys).to eq %w[CI_PIPELINE_ID CI_CONFIG_PATH CI_PIPELINE_SOURCE] end end diff --git a/spec/models/clusters/platforms/kubernetes_spec.rb b/spec/models/clusters/platforms/kubernetes_spec.rb index 53a4e545ff6..add481b8096 100644 --- a/spec/models/clusters/platforms/kubernetes_spec.rb +++ b/spec/models/clusters/platforms/kubernetes_spec.rb @@ -252,7 +252,7 @@ describe Clusters::Platforms::Kubernetes, :use_clean_rails_memory_store_caching stub_kubeclient_pods(status: 500) end - it { expect { subject }.to raise_error(KubeException) } + it { expect { subject }.to raise_error(Kubeclient::HttpError) } end context 'when kubernetes responds with 404s' do diff --git a/spec/models/compare_spec.rb b/spec/models/compare_spec.rb index 04f3cecae00..8e88bb81162 100644 --- a/spec/models/compare_spec.rb +++ b/spec/models/compare_spec.rb @@ -37,33 +37,51 @@ describe Compare do end end - describe '#base_commit' do - let(:base_commit) { Commit.new(another_sample_commit, project) } + describe '#base_commit_sha' do + it 'returns @base_sha if it is present' do + expect(project).not_to receive(:merge_base_commit) - it 'returns project merge base commit' do - expect(project).to receive(:merge_base_commit).with(start_commit.id, head_commit.id).and_return(base_commit) + sha = double + service = described_class.new(raw_compare, project, base_sha: sha) - expect(subject.base_commit).to eq(base_commit) + expect(service.base_commit_sha).to eq(sha) + end + + it 'fetches merge base SHA from repo when @base_sha is nil' do + expect(project).to receive(:merge_base_commit) + .with(start_commit.id, head_commit.id) + .once + .and_call_original + + expect(subject.base_commit_sha) + .to eq(project.repository.merge_base(start_commit.id, head_commit.id)) + end + + it 'is memoized on first call' do + expect(project).to receive(:merge_base_commit) + .with(start_commit.id, head_commit.id) + .once + .and_call_original + + 3.times { subject.base_commit_sha } end it 'returns nil if there is no start_commit' do expect(subject).to receive(:start_commit).and_return(nil) - expect(subject.base_commit).to eq(nil) + expect(subject.base_commit_sha).to eq(nil) end it 'returns nil if there is no head commit' do expect(subject).to receive(:head_commit).and_return(nil) - expect(subject.base_commit).to eq(nil) + expect(subject.base_commit_sha).to eq(nil) end end describe '#diff_refs' do - it 'uses base_commit sha as base_sha' do - expect(subject).to receive(:base_commit).at_least(:once).and_call_original - - expect(subject.diff_refs.base_sha).to eq(subject.base_commit.id) + it 'uses base_commit_sha sha as base_sha' do + expect(subject.diff_refs.base_sha).to eq(subject.base_commit_sha) end it 'uses start_commit sha as start_sha' do diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 4b217df2e8f..f8874d14e3f 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -34,7 +34,7 @@ describe Issuable do subject { build(:issue) } before do - allow(subject).to receive(:set_iid).and_return(false) + allow(InternalId).to receive(:generate_next).and_return(nil) end it { is_expected.to validate_presence_of(:project) } diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb new file mode 100644 index 00000000000..581fd0293cc --- /dev/null +++ b/spec/models/internal_id_spec.rb @@ -0,0 +1,106 @@ +require 'spec_helper' + +describe InternalId do + let(:project) { create(:project) } + let(:usage) { :issues } + let(:issue) { build(:issue, project: project) } + let(:scope) { { project: project } } + let(:init) { ->(s) { s.project.issues.size } } + + context 'validations' do + it { is_expected.to validate_presence_of(:usage) } + end + + describe '.generate_next' do + subject { described_class.generate_next(issue, scope, usage, init) } + + context 'in the absence of a record' do + it 'creates a record if not yet present' do + expect { subject }.to change { described_class.count }.from(0).to(1) + end + + it 'stores record attributes' do + subject + + described_class.first.tap do |record| + expect(record.project).to eq(project) + expect(record.usage).to eq(usage.to_s) + end + end + + context 'with existing issues' do + before do + rand(1..10).times { create(:issue, project: project) } + described_class.delete_all + end + + it 'calculates last_value values automatically' do + expect(subject).to eq(project.issues.size + 1) + end + end + + context 'with concurrent inserts on table' do + it 'looks up the record if it was created concurrently' do + args = { **scope, usage: described_class.usages[usage.to_s] } + record = double + expect(described_class).to receive(:find_by).with(args).and_return(nil) # first call, record not present + expect(described_class).to receive(:find_by).with(args).and_return(record) # second call, record was created by another process + expect(described_class).to receive(:create!).and_raise(ActiveRecord::RecordNotUnique, 'record not unique') + expect(record).to receive(:increment_and_save!) + + subject + end + end + end + + it 'generates a strictly monotone, gapless sequence' do + seq = (0..rand(100)).map do + described_class.generate_next(issue, scope, usage, init) + end + normalized = seq.map { |i| i - seq.min } + + expect(normalized).to eq((0..seq.size - 1).to_a) + end + + context 'with an insufficient schema version' do + before do + described_class.reset_column_information + expect(ActiveRecord::Migrator).to receive(:current_version).and_return(InternalId::REQUIRED_SCHEMA_VERSION - 1) + end + + let(:init) { double('block') } + + it 'calculates next internal ids on the fly' do + val = rand(1..100) + + expect(init).to receive(:call).with(issue).and_return(val) + expect(subject).to eq(val + 1) + end + end + end + + describe '#increment_and_save!' do + let(:id) { create(:internal_id) } + subject { id.increment_and_save! } + + it 'returns incremented iid' do + value = id.last_value + + expect(subject).to eq(value + 1) + end + + it 'saves the record' do + subject + + expect(id.changed?).to be_falsey + end + + context 'with last_value=nil' do + let(:id) { build(:internal_id, last_value: nil) } + + it 'returns 1' do + expect(subject).to eq(1) + end + end + end +end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index feed7968f09..11154291368 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -9,11 +9,17 @@ describe Issue do describe 'modules' do subject { described_class } - it { is_expected.to include_module(InternalId) } it { is_expected.to include_module(Issuable) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } it { is_expected.to include_module(Taskable) } + + it_behaves_like 'AtomicInternalId' do + let(:internal_id_attribute) { :iid } + let(:instance) { build(:issue) } + let(:scope_attrs) { { project: instance.project } } + let(:usage) { :issues } + end end subject { create(:issue) } diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 7986aa31e16..ff5a6f63010 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -17,7 +17,7 @@ describe MergeRequest do describe 'modules' do subject { described_class } - it { is_expected.to include_module(InternalId) } + it { is_expected.to include_module(NonatomicInternalId) } it { is_expected.to include_module(Issuable) } it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Sortable) } @@ -1544,7 +1544,7 @@ describe MergeRequest do end it "executes diff cache service" do - expect_any_instance_of(MergeRequests::MergeRequestDiffCacheService).to receive(:execute).with(subject) + expect_any_instance_of(MergeRequests::MergeRequestDiffCacheService).to receive(:execute).with(subject, an_instance_of(MergeRequestDiff)) subject.reload_diff end diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index e626efd054d..ee142718f7e 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -204,43 +204,67 @@ describe Namespace do expect(gitlab_shell.exists?(project.repository_storage_path, "#{namespace.path}/#{project.path}.git")).to be_truthy end - context 'with subgroups' do + context 'with subgroups', :nested_groups do let(:parent) { create(:group, name: 'parent', path: 'parent') } + let(:new_parent) { create(:group, name: 'new_parent', path: 'new_parent') } let(:child) { create(:group, name: 'child', path: 'child', parent: parent) } let!(:project) { create(:project_empty_repo, :legacy_storage, path: 'the-project', namespace: child, skip_disk_validation: true) } let(:uploads_dir) { FileUploader.root } let(:pages_dir) { File.join(TestEnv.pages_path) } + def expect_project_directories_at(namespace_path) + expected_repository_path = File.join(TestEnv.repos_path, namespace_path, 'the-project.git') + expected_upload_path = File.join(uploads_dir, namespace_path, 'the-project') + expected_pages_path = File.join(pages_dir, namespace_path, 'the-project') + + expect(File.directory?(expected_repository_path)).to be_truthy + expect(File.directory?(expected_upload_path)).to be_truthy + expect(File.directory?(expected_pages_path)).to be_truthy + end + before do + FileUtils.mkdir_p(File.join(TestEnv.repos_path, "#{project.full_path}.git")) FileUtils.mkdir_p(File.join(uploads_dir, project.full_path)) FileUtils.mkdir_p(File.join(pages_dir, project.full_path)) end context 'renaming child' do it 'correctly moves the repository, uploads and pages' do - expected_repository_path = File.join(TestEnv.repos_path, 'parent', 'renamed', 'the-project.git') - expected_upload_path = File.join(uploads_dir, 'parent', 'renamed', 'the-project') - expected_pages_path = File.join(pages_dir, 'parent', 'renamed', 'the-project') + child.update!(path: 'renamed') - child.update_attributes!(path: 'renamed') - - expect(File.directory?(expected_repository_path)).to be(true) - expect(File.directory?(expected_upload_path)).to be(true) - expect(File.directory?(expected_pages_path)).to be(true) + expect_project_directories_at('parent/renamed') end end context 'renaming parent' do it 'correctly moves the repository, uploads and pages' do - expected_repository_path = File.join(TestEnv.repos_path, 'renamed', 'child', 'the-project.git') - expected_upload_path = File.join(uploads_dir, 'renamed', 'child', 'the-project') - expected_pages_path = File.join(pages_dir, 'renamed', 'child', 'the-project') + parent.update!(path: 'renamed') + + expect_project_directories_at('renamed/child') + end + end + + context 'moving from one parent to another' do + it 'correctly moves the repository, uploads and pages' do + child.update!(parent: new_parent) - parent.update_attributes!(path: 'renamed') + expect_project_directories_at('new_parent/child') + end + end + + context 'moving from having a parent to root' do + it 'correctly moves the repository, uploads and pages' do + child.update!(parent: nil) + + expect_project_directories_at('child') + end + end + + context 'moving from root to having a parent' do + it 'correctly moves the repository, uploads and pages' do + parent.update!(parent: new_parent) - expect(File.directory?(expected_repository_path)).to be(true) - expect(File.directory?(expected_upload_path)).to be(true) - expect(File.directory?(expected_pages_path)).to be(true) + expect_project_directories_at('new_parent/parent/child') end end end @@ -525,7 +549,6 @@ describe Namespace do end end - # Note: Group transfers are not yet implemented context 'when a group is transferred into a root group' do context 'when the root group "Share with group lock" is enabled' do let(:root_group) { create(:group, share_with_group_lock: true) } diff --git a/spec/models/project_auto_devops_spec.rb b/spec/models/project_auto_devops_spec.rb index 296b91a771c..7545c0797e9 100644 --- a/spec/models/project_auto_devops_spec.rb +++ b/spec/models/project_auto_devops_spec.rb @@ -36,14 +36,14 @@ describe ProjectAutoDevops do end end - describe '#variables' do + describe '#predefined_variables' do let(:auto_devops) { build_stubbed(:project_auto_devops, project: project, domain: domain) } context 'when domain is defined' do let(:domain) { 'example.com' } it 'returns AUTO_DEVOPS_DOMAIN' do - expect(auto_devops.variables).to include(domain_variable) + expect(auto_devops.predefined_variables).to include(domain_variable) end end @@ -55,7 +55,7 @@ describe ProjectAutoDevops do allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return('example.com') end - it { expect(auto_devops.variables).to include(domain_variable) } + it { expect(auto_devops.predefined_variables).to include(domain_variable) } end context 'when there is no instance domain specified' do @@ -63,7 +63,7 @@ describe ProjectAutoDevops do allow(Gitlab::CurrentSettings).to receive(:auto_devops_domain).and_return(nil) end - it { expect(auto_devops.variables).not_to include(domain_variable) } + it { expect(auto_devops.predefined_variables).not_to include(domain_variable) } end end diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index 622d8844a72..3be023a48c1 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -370,7 +370,7 @@ describe KubernetesService, :use_clean_rails_memory_store_caching do stub_kubeclient_pods(status: 500) end - it { expect { subject }.to raise_error(KubeException) } + it { expect { subject }.to raise_error(Kubeclient::HttpError) } end context 'when kubernetes responds with 404s' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index e970cd7dfdb..4cf8d861595 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1378,7 +1378,7 @@ describe Project do context 'using a regular repository' do it 'creates the repository' do - expect(shell).to receive(:add_repository) + expect(shell).to receive(:create_repository) .with(project.repository_storage, project.disk_path) .and_return(true) @@ -1388,7 +1388,7 @@ describe Project do end it 'adds an error if the repository could not be created' do - expect(shell).to receive(:add_repository) + expect(shell).to receive(:create_repository) .with(project.repository_storage, project.disk_path) .and_return(false) @@ -1402,7 +1402,7 @@ describe Project do context 'using a forked repository' do it 'does nothing' do expect(project).to receive(:forked?).and_return(true) - expect(shell).not_to receive(:add_repository) + expect(shell).not_to receive(:create_repository) project.create_repository end @@ -1421,7 +1421,7 @@ describe Project do allow(project).to receive(:repository_exists?) .and_return(false) - allow(shell).to receive(:add_repository) + allow(shell).to receive(:create_repository) .with(project.repository_storage_path, project.disk_path) .and_return(true) @@ -1445,7 +1445,7 @@ describe Project do allow(project).to receive(:repository_exists?) .and_return(false) - expect(shell).to receive(:add_repository) + expect(shell).to receive(:create_repository) .with(project.repository_storage, project.disk_path) .and_return(true) diff --git a/spec/models/project_wiki_spec.rb b/spec/models/project_wiki_spec.rb index 8b4b5873704..d87c1ca14f0 100644 --- a/spec/models/project_wiki_spec.rb +++ b/spec/models/project_wiki_spec.rb @@ -74,7 +74,7 @@ describe ProjectWiki do # Create a fresh project which will not have a wiki project_wiki = described_class.new(create(:project), user) gitlab_shell = double(:gitlab_shell) - allow(gitlab_shell).to receive(:add_repository) + allow(gitlab_shell).to receive(:create_repository) allow(project_wiki).to receive(:gitlab_shell).and_return(gitlab_shell) expect { project_wiki.send(:wiki) }.to raise_exception(ProjectWiki::CouldNotCreateWikiError) diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 93a61c6ea71..e506c932d58 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -895,7 +895,7 @@ describe Repository do end it 'returns nil when the content is not recognizable' do - repository.create_file(user, 'LICENSE', 'Copyright!', + repository.create_file(user, 'LICENSE', 'Gitlab B.V.', message: 'Add LICENSE', branch_name: 'master') expect(repository.license_key).to be_nil @@ -939,7 +939,7 @@ describe Repository do end it 'returns nil when the content is not recognizable' do - repository.create_file(user, 'LICENSE', 'Copyright!', + repository.create_file(user, 'LICENSE', 'Gitlab B.V.', message: 'Add LICENSE', branch_name: 'master') expect(repository.license).to be_nil @@ -2169,15 +2169,6 @@ describe Repository do end end - describe '#expire_method_caches' do - it 'expires the caches of the given methods' do - expect_any_instance_of(RepositoryCache).to receive(:expire).with(:readme) - expect_any_instance_of(RepositoryCache).to receive(:expire).with(:gitignore) - - repository.expire_method_caches(%i(readme gitignore)) - end - end - describe '#expire_all_method_caches' do it 'expires the caches of all methods' do expect(repository).to receive(:expire_method_caches) @@ -2323,66 +2314,6 @@ describe Repository do end end - describe '#cache_method_output', :use_clean_rails_memory_store_caching do - let(:fallback) { 10 } - - context 'with a non-existing repository' do - let(:project) { create(:project) } # No repository - - subject do - repository.cache_method_output(:cats, fallback: fallback) do - repository.cats_call_stub - end - end - - it 'returns the fallback value' do - expect(subject).to eq(fallback) - end - - it 'avoids calling the original method' do - expect(repository).not_to receive(:cats_call_stub) - - subject - end - end - - context 'with a method throwing a non-existing-repository error' do - subject do - repository.cache_method_output(:cats, fallback: fallback) do - raise Gitlab::Git::Repository::NoRepository - end - end - - it 'returns the fallback value' do - expect(subject).to eq(fallback) - end - - it 'does not cache the data' do - subject - - expect(repository.instance_variable_defined?(:@cats)).to eq(false) - expect(repository.send(:cache).exist?(:cats)).to eq(false) - end - end - - context 'with an existing repository' do - it 'caches the output' do - object = double - - expect(object).to receive(:number).once.and_return(10) - - 2.times do - val = repository.cache_method_output(:cats) { object.number } - - expect(val).to eq(10) - end - - expect(repository.send(:cache).exist?(:cats)).to eq(true) - expect(repository.instance_variable_get(:@cats)).to eq(10) - end - end - end - describe '#refresh_method_caches' do it 'refreshes the caches of the given types' do expect(repository).to receive(:expire_method_caches) diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index ca0aac87ba9..3cb90a1b8ef 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -447,6 +447,12 @@ describe API::Internal do expect(response).to have_gitlab_http_status(200) expect(json_response["status"]).to be_truthy + expect(json_response["gitaly"]).not_to be_nil + expect(json_response["gitaly"]["repository"]).not_to be_nil + expect(json_response["gitaly"]["repository"]["storage_name"]).to eq(project.repository.gitaly_repository.storage_name) + expect(json_response["gitaly"]["repository"]["relative_path"]).to eq(project.repository.gitaly_repository.relative_path) + expect(json_response["gitaly"]["address"]).to eq(Gitlab::GitalyClient.address(project.repository_storage)) + expect(json_response["gitaly"]["token"]).to eq(Gitlab::GitalyClient.token(project.repository_storage)) end end diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb index fbed527963f..12583109b59 100644 --- a/spec/requests/api/project_export_spec.rb +++ b/spec/requests/api/project_export_spec.rb @@ -285,6 +285,17 @@ describe API::ProjectExport do context 'when user is not a member' do it_behaves_like 'post project export start not found' end + + context 'when overriding description' do + it 'starts' do + params = { description: "Foo" } + + expect_any_instance_of(Projects::ImportExport::ExportService).to receive(:execute) + post api(path, project.owner), params + + expect(response).to have_gitlab_http_status(202) + end + end end end end diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb index 9052a18c60b..f8d5258a8d9 100644 --- a/spec/requests/api/search_spec.rb +++ b/spec/requests/api/search_spec.rb @@ -99,10 +99,10 @@ describe API::Search do end end - describe "GET /groups/:id/-/search" do + describe "GET /groups/:id/search" do context 'when user is not authenticated' do it 'returns 401 error' do - get api("/groups/#{group.id}/-/search"), scope: 'projects', search: 'awesome' + get api("/groups/#{group.id}/search"), scope: 'projects', search: 'awesome' expect(response).to have_gitlab_http_status(401) end @@ -110,7 +110,7 @@ describe API::Search do context 'when scope is not supported' do it 'returns 400 error' do - get api("/groups/#{group.id}/-/search", user), scope: 'unsupported', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'unsupported', search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -118,7 +118,7 @@ describe API::Search do context 'when scope is missing' do it 'returns 400 error' do - get api("/groups/#{group.id}/-/search", user), search: 'awesome' + get api("/groups/#{group.id}/search", user), search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -126,7 +126,7 @@ describe API::Search do context 'when group does not exist' do it 'returns 404 error' do - get api('/groups/9999/-/search', user), scope: 'issues', search: 'awesome' + get api('/groups/9999/search', user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -136,7 +136,7 @@ describe API::Search do it 'returns 404 error' do private_group = create(:group, :private) - get api("/groups/#{private_group.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/groups/#{private_group.id}/search", user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -145,7 +145,7 @@ describe API::Search do context 'with correct params' do context 'for projects scope' do before do - get api("/groups/#{group.id}/-/search", user), scope: 'projects', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'projects', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/projects' @@ -155,7 +155,7 @@ describe API::Search do before do create(:issue, project: project, title: 'awesome issue') - get api("/groups/#{group.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'issues', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/issues' @@ -165,7 +165,7 @@ describe API::Search do before do create(:merge_request, source_project: repo_project, title: 'awesome mr') - get api("/groups/#{group.id}/-/search", user), scope: 'merge_requests', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'merge_requests', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' @@ -175,7 +175,7 @@ describe API::Search do before do create(:milestone, project: project, title: 'awesome milestone') - get api("/groups/#{group.id}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/groups/#{group.id}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -187,7 +187,7 @@ describe API::Search do create(:milestone, project: project, title: 'awesome milestone') create(:milestone, project: another_project, title: 'awesome milestone other project') - get api("/groups/#{CGI.escape(group.full_path)}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/groups/#{CGI.escape(group.full_path)}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -198,7 +198,7 @@ describe API::Search do describe "GET /projects/:id/search" do context 'when user is not authenticated' do it 'returns 401 error' do - get api("/projects/#{project.id}/-/search"), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search"), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(401) end @@ -206,7 +206,7 @@ describe API::Search do context 'when scope is not supported' do it 'returns 400 error' do - get api("/projects/#{project.id}/-/search", user), scope: 'unsupported', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'unsupported', search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -214,7 +214,7 @@ describe API::Search do context 'when scope is missing' do it 'returns 400 error' do - get api("/projects/#{project.id}/-/search", user), search: 'awesome' + get api("/projects/#{project.id}/search", user), search: 'awesome' expect(response).to have_gitlab_http_status(400) end @@ -222,7 +222,7 @@ describe API::Search do context 'when project does not exist' do it 'returns 404 error' do - get api('/projects/9999/-/search', user), scope: 'issues', search: 'awesome' + get api('/projects/9999/search', user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -232,7 +232,7 @@ describe API::Search do it 'returns 404 error' do project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE) - get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome' expect(response).to have_gitlab_http_status(404) end @@ -243,7 +243,7 @@ describe API::Search do before do create(:issue, project: project, title: 'awesome issue') - get api("/projects/#{project.id}/-/search", user), scope: 'issues', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'issues', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/issues' @@ -253,7 +253,7 @@ describe API::Search do before do create(:merge_request, source_project: repo_project, title: 'awesome mr') - get api("/projects/#{repo_project.id}/-/search", user), scope: 'merge_requests', search: 'awesome' + get api("/projects/#{repo_project.id}/search", user), scope: 'merge_requests', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/merge_requests' @@ -263,7 +263,7 @@ describe API::Search do before do create(:milestone, project: project, title: 'awesome milestone') - get api("/projects/#{project.id}/-/search", user), scope: 'milestones', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'milestones', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/milestones' @@ -273,7 +273,7 @@ describe API::Search do before do create(:note_on_merge_request, project: project, note: 'awesome note') - get api("/projects/#{project.id}/-/search", user), scope: 'notes', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'notes', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/notes' @@ -284,7 +284,7 @@ describe API::Search do wiki = create(:project_wiki, project: project) create(:wiki_page, wiki: wiki, attrs: { title: 'home', content: "Awesome page" }) - get api("/projects/#{project.id}/-/search", user), scope: 'wiki_blobs', search: 'awesome' + get api("/projects/#{project.id}/search", user), scope: 'wiki_blobs', search: 'awesome' end it_behaves_like 'response is correct', schema: 'public_api/v4/blobs' @@ -292,7 +292,7 @@ describe API::Search do context 'for commits scope' do before do - get api("/projects/#{repo_project.id}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' + get api("/projects/#{repo_project.id}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' end it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details' @@ -300,7 +300,7 @@ describe API::Search do context 'for commits scope with project path as id' do before do - get api("/projects/#{CGI.escape(repo_project.full_path)}/-/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' + get api("/projects/#{CGI.escape(repo_project.full_path)}/search", user), scope: 'commits', search: '498214de67004b1da3d820901307bed2a68a8ef6' end it_behaves_like 'response is correct', schema: 'public_api/v4/commits_details' @@ -308,7 +308,7 @@ describe API::Search do context 'for blobs scope' do before do - get api("/projects/#{repo_project.id}/-/search", user), scope: 'blobs', search: 'monitors' + get api("/projects/#{repo_project.id}/search", user), scope: 'blobs', search: 'monitors' end it_behaves_like 'response is correct', schema: 'public_api/v4/blobs', size: 2 diff --git a/spec/requests/api/templates_spec.rb b/spec/requests/api/templates_spec.rb index de1619f33c1..6bb53fdc98d 100644 --- a/spec/requests/api/templates_spec.rb +++ b/spec/requests/api/templates_spec.rb @@ -65,7 +65,7 @@ describe API::Templates do expect(json_response['description']).to include('A short and simple permissive license with conditions') expect(json_response['conditions']).to eq(%w[include-copyright]) expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) - expect(json_response['limitations']).to eq(%w[no-liability]) + expect(json_response['limitations']).to eq(%w[liability warranty]) expect(json_response['content']).to include('MIT License') end end diff --git a/spec/requests/api/v3/templates_spec.rb b/spec/requests/api/v3/templates_spec.rb index 38a8994eb79..1a637f3cf96 100644 --- a/spec/requests/api/v3/templates_spec.rb +++ b/spec/requests/api/v3/templates_spec.rb @@ -57,7 +57,7 @@ describe API::V3::Templates do expect(json_response['description']).to include('A short and simple permissive license with conditions') expect(json_response['conditions']).to eq(%w[include-copyright]) expect(json_response['permissions']).to eq(%w[commercial-use modifications distribution private-use]) - expect(json_response['limitations']).to eq(%w[no-liability]) + expect(json_response['limitations']).to eq(%w[liability warranty]) expect(json_response['content']).to include('MIT License') end end diff --git a/spec/services/ci/process_pipeline_service_spec.rb b/spec/services/ci/process_pipeline_service_spec.rb index 0ce41e7c7ee..feb5120bc68 100644 --- a/spec/services/ci/process_pipeline_service_spec.rb +++ b/spec/services/ci/process_pipeline_service_spec.rb @@ -9,6 +9,8 @@ describe Ci::ProcessPipelineService, '#execute' do end before do + stub_ci_pipeline_to_return_yaml_file + stub_not_protect_default_branch project.add_developer(user) diff --git a/spec/services/clusters/applications/install_service_spec.rb b/spec/services/clusters/applications/install_service_spec.rb index ad175226e92..93199964a0e 100644 --- a/spec/services/clusters/applications/install_service_spec.rb +++ b/spec/services/clusters/applications/install_service_spec.rb @@ -34,7 +34,7 @@ describe Clusters::Applications::InstallService do context 'when k8s cluster communication fails' do before do - error = KubeException.new(500, 'system failure', nil) + error = Kubeclient::HttpError.new(500, 'system failure', nil) expect(helm_client).to receive(:install).with(install_command).and_raise(error) end diff --git a/spec/services/files/create_service_spec.rb b/spec/services/files/create_service_spec.rb index 030263b1502..abe99b9e794 100644 --- a/spec/services/files/create_service_spec.rb +++ b/spec/services/files/create_service_spec.rb @@ -43,7 +43,7 @@ describe Files::CreateService do blob = repository.blob_at('lfs', file_path) - expect(blob.data).not_to start_with('version https://git-lfs.github.com/spec/v1') + expect(blob.data).not_to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) expect(blob.data).to eq(file_content) end end @@ -58,7 +58,7 @@ describe Files::CreateService do blob = repository.blob_at('lfs', file_path) - expect(blob.data).to start_with('version https://git-lfs.github.com/spec/v1') + expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) end it "creates an LfsObject with the file's content" do diff --git a/spec/services/files/multi_service_spec.rb b/spec/services/files/multi_service_spec.rb index b9971776b33..59984c10990 100644 --- a/spec/services/files/multi_service_spec.rb +++ b/spec/services/files/multi_service_spec.rb @@ -4,28 +4,30 @@ describe Files::MultiService do subject { described_class.new(project, user, commit_params) } let(:project) { create(:project, :repository) } + let(:repository) { project.repository } let(:user) { create(:user) } let(:branch_name) { project.default_branch } let(:original_file_path) { 'files/ruby/popen.rb' } let(:new_file_path) { 'files/ruby/popen.rb' } + let(:file_content) { 'New content' } let(:action) { 'update' } let!(:original_commit_id) do Gitlab::Git::Commit.last_for_path(project.repository, branch_name, original_file_path).sha end - let(:actions) do - [ - { - action: action, - file_path: new_file_path, - previous_path: original_file_path, - content: 'New content', - last_commit_id: original_commit_id - } - ] + let(:default_action) do + { + action: action, + file_path: new_file_path, + previous_path: original_file_path, + content: file_content, + last_commit_id: original_commit_id + } end + let(:actions) { [default_action] } + let(:commit_params) do { commit_message: "Update File", @@ -110,6 +112,56 @@ describe Files::MultiService do end end + context 'when creating a file matching an LFS filter' do + let(:action) { 'create' } + let(:branch_name) { 'lfs' } + let(:new_file_path) { 'test_file.lfs' } + + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end + + it 'creates an LFS pointer' do + subject.execute + + blob = repository.blob_at('lfs', new_file_path) + + expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) + end + + it "creates an LfsObject with the file's content" do + subject.execute + + expect(LfsObject.last.file.read).to eq file_content + end + + context 'with base64 encoded content' do + let(:raw_file_content) { 'Raw content' } + let(:file_content) { Base64.encode64(raw_file_content) } + let(:actions) { [default_action.merge(encoding: 'base64')] } + + it 'creates an LFS pointer' do + subject.execute + + blob = repository.blob_at('lfs', new_file_path) + + expect(blob.data).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) + end + + it "creates an LfsObject with the file's content" do + subject.execute + + expect(LfsObject.last.file.read).to eq raw_file_content + end + end + + it 'links the LfsObject to the project' do + expect do + subject.execute + end.to change { project.lfs_objects.count }.by(1) + end + end + context 'when file status validation is skipped' do let(:action) { 'create' } let(:new_file_path) { 'files/ruby/new_file.rb' } diff --git a/spec/services/lfs/file_transformer_spec.rb b/spec/services/lfs/file_transformer_spec.rb new file mode 100644 index 00000000000..e8938338cb7 --- /dev/null +++ b/spec/services/lfs/file_transformer_spec.rb @@ -0,0 +1,97 @@ +require "spec_helper" + +describe Lfs::FileTransformer do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:file_content) { 'Test file content' } + let(:branch_name) { 'lfs' } + let(:file_path) { 'test_file.lfs' } + + subject { described_class.new(project, branch_name) } + + describe '#new_file' do + context 'with lfs disabled' do + it 'skips gitattributes check' do + expect(repository.raw).not_to receive(:blob_at) + + subject.new_file(file_path, file_content) + end + + it 'returns untransformed content' do + result = subject.new_file(file_path, file_content) + + expect(result.content).to eq(file_content) + end + + it 'returns untransformed encoding' do + result = subject.new_file(file_path, file_content, encoding: 'base64') + + expect(result.encoding).to eq('base64') + end + end + + context 'with lfs enabled' do + before do + allow(project).to receive(:lfs_enabled?).and_return(true) + end + + it 'reuses cached gitattributes' do + second_file = 'another_file.lfs' + + expect(repository.raw).to receive(:blob_at).with(branch_name, '.gitattributes').once + + subject.new_file(file_path, file_content) + subject.new_file(second_file, file_content) + end + + it "creates an LfsObject with the file's content" do + subject.new_file(file_path, file_content) + + expect(LfsObject.last.file.read).to eq file_content + end + + it 'returns an LFS pointer' do + result = subject.new_file(file_path, file_content) + + expect(result.content).to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) + end + + it 'returns LFS pointer encoding as text' do + result = subject.new_file(file_path, file_content, encoding: 'base64') + + expect(result.encoding).to eq('text') + end + + context "when doesn't use LFS" do + let(:file_path) { 'other.filetype' } + + it "doesn't create LFS pointers" do + new_content = subject.new_file(file_path, file_content).content + + expect(new_content).not_to start_with(Gitlab::Git::LfsPointerFile::VERSION_LINE) + expect(new_content).to eq(file_content) + end + end + + it 'links LfsObjects to project' do + expect do + subject.new_file(file_path, file_content) + end.to change { project.lfs_objects.count }.by(1) + end + + context 'when LfsObject already exists' do + let(:lfs_pointer) { Gitlab::Git::LfsPointerFile.new(file_content) } + + before do + create(:lfs_object, oid: lfs_pointer.sha256, size: lfs_pointer.size) + end + + it 'links LfsObjects to project' do + expect do + subject.new_file(file_path, file_content) + end.to change { project.lfs_objects.count }.by(1) + end + end + end + end +end diff --git a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb index bb46e1dd9ab..57b6165cfb0 100644 --- a/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb +++ b/spec/services/merge_requests/merge_request_diff_cache_service_spec.rb @@ -1,19 +1,39 @@ require 'spec_helper' -describe MergeRequests::MergeRequestDiffCacheService do +describe MergeRequests::MergeRequestDiffCacheService, :use_clean_rails_memory_store_caching do let(:subject) { described_class.new } + let(:merge_request) { create(:merge_request) } describe '#execute' do - it 'retrieves the diff files to cache the highlighted result' do - merge_request = create(:merge_request) - cache_key = [merge_request.merge_request_diff, 'highlighted-diff-files', Gitlab::Diff::FileCollection::MergeRequestDiff.default_options] - - expect(Rails.cache).to receive(:read).with(cache_key).and_return({}) - expect(Rails.cache).to receive(:write).with(cache_key, anything) + before do allow_any_instance_of(Gitlab::Diff::File).to receive(:text?).and_return(true) allow_any_instance_of(Gitlab::Diff::File).to receive(:diffable?).and_return(true) + end + + it 'retrieves the diff files to cache the highlighted result' do + new_diff = merge_request.merge_request_diff + cache_key = new_diff.diffs.cache_key + + expect(Rails.cache).to receive(:read).with(cache_key).and_call_original + expect(Rails.cache).to receive(:write).with(cache_key, anything, anything).and_call_original + + subject.execute(merge_request, new_diff) + end + + it 'clears the cache for older diffs on the merge request' do + old_diff = merge_request.merge_request_diff + old_cache_key = old_diff.diffs.cache_key + + subject.execute(merge_request, old_diff) + + new_diff = merge_request.create_merge_request_diff + new_cache_key = new_diff.diffs.cache_key + + expect(Rails.cache).to receive(:delete).with(old_cache_key).and_call_original + expect(Rails.cache).to receive(:read).with(new_cache_key).and_call_original + expect(Rails.cache).to receive(:write).with(new_cache_key, anything, anything).and_call_original - subject.execute(merge_request) + subject.execute(merge_request, new_diff) end end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 62fdf870090..3943148f0db 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -34,6 +34,12 @@ describe NotificationService, :mailer do should_not_email_anyone end + it 'emails new mentions despite being unsubscribed' do + send_notifications(@unsubscribed_mentioned) + + should_only_email(@unsubscribed_mentioned) + end + it 'sends the proper notification reason header' do send_notifications(@u_watcher) should_only_email(@u_watcher) @@ -122,7 +128,7 @@ describe NotificationService, :mailer do let(:project) { create(:project, :private) } let(:issue) { create(:issue, project: project, assignees: [assignee]) } let(:mentioned_issue) { create(:issue, assignees: issue.assignees) } - let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @outsider also') } + let(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') } before do build_team(note.project) @@ -150,7 +156,7 @@ describe NotificationService, :mailer do add_users_with_subscription(note.project, issue) reset_delivered_emails! - expect(SentNotification).to receive(:record).with(issue, any_args).exactly(9).times + expect(SentNotification).to receive(:record).with(issue, any_args).exactly(10).times notification.new_note(note) @@ -163,6 +169,7 @@ describe NotificationService, :mailer do should_email(@watcher_and_subscriber) should_email(@subscribed_participant) should_email(@u_custom_off) + should_email(@unsubscribed_mentioned) should_not_email(@u_guest_custom) should_not_email(@u_guest_watcher) should_not_email(note.author) @@ -279,6 +286,7 @@ describe NotificationService, :mailer do before do build_team(note.project) note.project.add_master(note.author) + add_users_with_subscription(note.project, issue) reset_delivered_emails! end @@ -286,6 +294,9 @@ describe NotificationService, :mailer do it 'notifies the team members' do notification.new_note(note) + # Make sure @unsubscribed_mentioned is part of the team + expect(note.project.team.members).to include(@unsubscribed_mentioned) + # Notify all team members note.project.team.members.each do |member| # User with disabled notification should not be notified @@ -486,7 +497,7 @@ describe NotificationService, :mailer do let(:group) { create(:group) } let(:project) { create(:project, :public, namespace: group) } let(:another_project) { create(:project, :public, namespace: group) } - let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant' } + let(:issue) { create :issue, project: project, assignees: [assignee], description: 'cc @participant @unsubscribed_mentioned' } before do build_team(issue.project) @@ -510,6 +521,7 @@ describe NotificationService, :mailer do should_email(@u_participant_mentioned) should_email(@g_global_watcher) should_email(@g_watcher) + should_email(@unsubscribed_mentioned) should_not_email(@u_mentioned) should_not_email(@u_participating) should_not_email(@u_disabled) @@ -1823,6 +1835,7 @@ describe NotificationService, :mailer do def add_users_with_subscription(project, issuable) @subscriber = create :user @unsubscriber = create :user + @unsubscribed_mentioned = create :user, username: 'unsubscribed_mentioned' @subscribed_participant = create_global_setting_for(create(:user, username: 'subscribed_participant'), :participating) @watcher_and_subscriber = create_global_setting_for(create(:user), :watch) @@ -1830,7 +1843,9 @@ describe NotificationService, :mailer do project.add_master(@subscriber) project.add_master(@unsubscriber) project.add_master(@watcher_and_subscriber) + project.add_master(@unsubscribed_mentioned) + issuable.subscriptions.create(user: @unsubscribed_mentioned, project: project, subscribed: false) issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true) issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true) issuable.subscriptions.create(user: @unsubscriber, project: project, subscribed: false) diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb index 9a44dfde41b..8471467d2fa 100644 --- a/spec/services/projects/create_service_spec.rb +++ b/spec/services/projects/create_service_spec.rb @@ -164,7 +164,7 @@ describe Projects::CreateService, '#execute' do context 'with legacy storage' do before do - gitlab_shell.add_repository(repository_storage, "#{user.namespace.full_path}/existing") + gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing") end after do @@ -200,7 +200,7 @@ describe Projects::CreateService, '#execute' do end before do - gitlab_shell.add_repository(repository_storage, hashed_path) + gitlab_shell.create_repository(repository_storage, hashed_path) end after do diff --git a/spec/services/projects/fork_service_spec.rb b/spec/services/projects/fork_service_spec.rb index 409d5de8d43..d1011b07db6 100644 --- a/spec/services/projects/fork_service_spec.rb +++ b/spec/services/projects/fork_service_spec.rb @@ -108,7 +108,7 @@ describe Projects::ForkService do let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } before do - gitlab_shell.add_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}") + gitlab_shell.create_repository(repository_storage, "#{@to_user.namespace.full_path}/#{@from_project.path}") end after do diff --git a/spec/services/projects/transfer_service_spec.rb b/spec/services/projects/transfer_service_spec.rb index ae0e22e3dc0..ce567fe3879 100644 --- a/spec/services/projects/transfer_service_spec.rb +++ b/spec/services/projects/transfer_service_spec.rb @@ -151,7 +151,7 @@ describe Projects::TransferService do before do group.add_owner(user) - unless gitlab_shell.add_repository(repository_storage, "#{group.full_path}/#{project.path}") + unless gitlab_shell.create_repository(repository_storage, "#{group.full_path}/#{project.path}") raise 'failed to add repository' end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index ad5a289290c..f3f97b6b921 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -132,6 +132,15 @@ describe Projects::UpdateService do expect(result).to eq({ status: :success }) expect(project.wiki_repository_exists?).to be false end + + it 'handles empty project feature attributes' do + project.project_feature.update(wiki_access_level: ProjectFeature::DISABLED) + + result = update_project(project, user, { name: 'test1' }) + + expect(result).to eq({ status: :success }) + expect(project.wiki_repository_exists?).to be false + end end context 'when enabling a wiki' do @@ -187,7 +196,7 @@ describe Projects::UpdateService do let(:project) { create(:project, :legacy_storage, :repository, creator: user, namespace: user.namespace) } before do - gitlab_shell.add_repository(repository_storage, "#{user.namespace.full_path}/existing") + gitlab_shell.create_repository(repository_storage, "#{user.namespace.full_path}/existing") end after do diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index a3893188c6e..e28b0ea5cf2 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -743,7 +743,7 @@ describe SystemNoteService do expect(cross_reference(type)).to eq("Events for #{type.pluralize.humanize.downcase} are disabled.") end - it "blocks cross reference when #{type.underscore}_events is true" do + it "creates cross reference when #{type.underscore}_events is true" do jira_tracker.update("#{type}_events" => true) expect(cross_reference(type)).to eq(success_message) diff --git a/spec/support/shared_examples/models/atomic_internal_id_spec.rb b/spec/support/shared_examples/models/atomic_internal_id_spec.rb new file mode 100644 index 00000000000..144af4fc475 --- /dev/null +++ b/spec/support/shared_examples/models/atomic_internal_id_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +shared_examples_for 'AtomicInternalId' do + describe '.has_internal_id' do + describe 'Module inclusion' do + subject { described_class } + + it { is_expected.to include_module(AtomicInternalId) } + end + + describe 'Validation' do + subject { instance } + + before do + allow(InternalId).to receive(:generate_next).and_return(nil) + end + + it { is_expected.to validate_presence_of(internal_id_attribute) } + it { is_expected.to validate_numericality_of(internal_id_attribute) } + end + + describe 'internal id generation' do + subject { instance.save! } + + it 'calls InternalId.generate_next and sets internal id attribute' do + iid = rand(1..1000) + + expect(InternalId).to receive(:generate_next).with(instance, scope_attrs, usage, any_args).and_return(iid) + subject + expect(instance.public_send(internal_id_attribute)).to eq(iid) + end + + it 'does not overwrite an existing internal id' do + instance.public_send("#{internal_id_attribute}=", 4711) + + expect { subject }.not_to change { instance.public_send(internal_id_attribute) } + end + end + end +end diff --git a/spec/tasks/gitlab/artifacts/check_rake_spec.rb b/spec/tasks/gitlab/artifacts/check_rake_spec.rb new file mode 100644 index 00000000000..d495b08aca0 --- /dev/null +++ b/spec/tasks/gitlab/artifacts/check_rake_spec.rb @@ -0,0 +1,34 @@ +require 'rake_helper' + +describe 'gitlab:artifacts rake tasks' do + describe 'check' do + let!(:artifact) { create(:ci_job_artifact, :archive, :correct_checksum) } + + before do + Rake.application.rake_require('tasks/gitlab/artifacts/check') + stub_env('VERBOSE' => 'true') + end + + it 'outputs the integrity check for each batch' do + expect { run_rake_task('gitlab:artifacts:check') }.to output(/Failures: 0/).to_stdout + end + + it 'errors out about missing files on the file system' do + FileUtils.rm_f(artifact.file.path) + + expect { run_rake_task('gitlab:artifacts:check') }.to output(/No such file.*#{Regexp.quote(artifact.file.path)}/).to_stdout + end + + it 'errors out about invalid checksum' do + artifact.update_column(:file_sha256, 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + + expect { run_rake_task('gitlab:artifacts:check') }.to output(/Checksum mismatch/).to_stdout + end + + it 'errors out about missing checksum' do + artifact.update_column(:file_sha256, nil) + + expect { run_rake_task('gitlab:artifacts:check') }.to output(/Checksum missing/).to_stdout + end + end +end diff --git a/spec/views/projects/diffs/_stats.html.haml_spec.rb b/spec/views/projects/diffs/_stats.html.haml_spec.rb new file mode 100644 index 00000000000..c7d2f85747c --- /dev/null +++ b/spec/views/projects/diffs/_stats.html.haml_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe 'projects/diffs/_stats.html.haml' do + let(:project) { create(:project, :repository) } + let(:commit) { project.commit('570e7b2abdd848b95f2f578043fc23bd6f6fd24d') } + + def render_view + render partial: "projects/diffs/stats", locals: { diff_files: commit.diffs.diff_files } + end + + context 'when the commit contains several changes' do + it 'uses plural for additions' do + render_view + + expect(rendered).to have_text('additions') + end + + it 'uses plural for deletions' do + render_view + end + end + + context 'when the commit contains no addition and no deletions' do + let(:commit) { project.commit('4cd80ccab63c82b4bad16faa5193fbd2aa06df40') } + + it 'uses plural for additions' do + render_view + + expect(rendered).to have_text('additions') + end + + it 'uses plural for deletions' do + render_view + + expect(rendered).to have_text('deletions') + end + end + + context 'when the commit contains exactly one addition and one deletion' do + let(:commit) { project.commit('08f22f255f082689c0d7d39d19205085311542bc') } + + it 'uses singular for additions' do + render_view + + expect(rendered).to have_text('addition') + expect(rendered).not_to have_text('additions') + end + + it 'uses singular for deletions' do + render_view + + expect(rendered).to have_text('deletion') + expect(rendered).not_to have_text('deletions') + end + end +end diff --git a/spec/views/projects/services/_form.haml_spec.rb b/spec/views/projects/services/_form.haml_spec.rb new file mode 100644 index 00000000000..85167bca115 --- /dev/null +++ b/spec/views/projects/services/_form.haml_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe 'projects/services/_form' do + let(:project) { create(:redmine_project) } + let(:user) { create(:admin) } + + before do + assign(:project, project) + + allow(controller).to receive(:current_user).and_return(user) + + allow(view).to receive_messages(current_user: user, + can?: true, + current_application_settings: Gitlab::CurrentSettings.current_application_settings) + end + + context 'commit_events and merge_request_events' do + before do + assign(:service, project.redmine_service) + end + + it 'display merge_request_events and commit_events descriptions' do + allow(RedmineService).to receive(:supported_events).and_return(%w(commit merge_request)) + + render + + expect(rendered).to have_content('Event will be triggered when a commit is created/updated') + expect(rendered).to have_content('Event will be triggered when a merge request is created/updated/merged') + end + + context 'when service is JIRA' do + let(:project) { create(:jira_project) } + + before do + assign(:service, project.jira_service) + end + + it 'display merge_request_events and commit_events descriptions' do + render + + expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a commit.') + expect(rendered).to have_content('JIRA comments will be created when an issue gets referenced in a merge request.') + end + end + end +end |