From 0f58eb6bde35009b69ef871534d9ff80fc38bbf7 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 2 May 2017 17:42:37 -0500 Subject: Add artifact file page that uses the blob viewer --- app/assets/javascripts/dispatcher.js | 3 + app/controllers/projects/artifacts_controller.rb | 28 ++- app/helpers/blob_helper.rb | 6 +- app/helpers/gitlab_routing_helper.rb | 2 + app/models/ci/artifact_blob.rb | 35 ++++ app/views/projects/artifacts/_tree_file.html.haml | 9 +- app/views/projects/artifacts/file.html.haml | 33 ++++ changelogs/unreleased/dm-artifact-blob-viewer.yml | 4 + config/routes/project.rb | 1 + features/project/builds/artifacts.feature | 3 +- features/steps/project/builds/artifacts.rb | 15 +- lib/gitlab/ci/build/artifacts/metadata/entry.rb | 6 + .../projects/artifacts_controller_spec.rb | 188 +++++++++++++++++++++ spec/features/projects/artifacts/file_spec.rb | 59 +++++++ .../ci/build/artifacts/metadata/entry_spec.rb | 11 ++ spec/models/ci/artifact_blob_spec.rb | 44 +++++ .../requests/projects/artifacts_controller_spec.rb | 117 ------------- 17 files changed, 425 insertions(+), 139 deletions(-) create mode 100644 app/models/ci/artifact_blob.rb create mode 100644 app/views/projects/artifacts/file.html.haml create mode 100644 changelogs/unreleased/dm-artifact-blob-viewer.yml create mode 100644 spec/controllers/projects/artifacts_controller_spec.rb create mode 100644 spec/features/projects/artifacts/file_spec.rb create mode 100644 spec/models/ci/artifact_blob_spec.rb delete mode 100644 spec/requests/projects/artifacts_controller_spec.rb diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 0bdce52cc89..7e469153106 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -344,6 +344,9 @@ const ShortcutsBlob = require('./shortcuts_blob'); case 'projects:artifacts:browse': new BuildArtifacts(); break; + case 'projects:artifacts:file': + new BlobViewer(); + break; case 'help:index': gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); break; diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index a13588b4218..1224e9503c9 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,11 +1,13 @@ class Projects::ArtifactsController < Projects::ApplicationController include ExtractsPath + include RendersBlob layout 'project' before_action :authorize_read_build! before_action :authorize_update_build!, only: [:keep] before_action :extract_ref_name_and_path before_action :validate_artifacts! + before_action :set_path_and_entry, only: [:file, :raw] def download if artifacts_file.file_storage? @@ -24,15 +26,24 @@ class Projects::ArtifactsController < Projects::ApplicationController end def file - entry = build.artifacts_metadata_entry(params[:path]) + blob = @entry.blob + override_max_blob_size(blob) - if entry.exists? - send_artifacts_entry(build, entry) - else - render_404 + respond_to do |format| + format.html do + render 'file' + end + + format.json do + render_blob_json(blob) + end end end + def raw + send_artifacts_entry(build, @entry) + end + def keep build.keep_artifacts! redirect_to namespace_project_build_path(project.namespace, project, build) @@ -81,4 +92,11 @@ class Projects::ArtifactsController < Projects::ApplicationController def artifacts_file @artifacts_file ||= build.artifacts_file end + + def set_path_and_entry + @path = params[:path] + @entry = build.artifacts_metadata_entry(@path) + + render_404 unless @entry.exists? + end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 37b6f4ad5cc..af430270ae4 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -119,7 +119,9 @@ module BlobHelper end def blob_raw_url - if @snippet + if @build && @entry + raw_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: @entry.path) + elsif @snippet if @snippet.project_id raw_namespace_project_snippet_path(@project.namespace, @project, @snippet) else @@ -250,6 +252,8 @@ module BlobHelper case viewer.blob.external_storage when :lfs 'it is stored in LFS' + when :build_artifact + 'it is stored as a job artifact' else 'it is stored externally' end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index e9b7cbbad6a..1336c676134 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -208,6 +208,8 @@ module GitlabRoutingHelper browse_namespace_project_build_artifacts_path(*args) when 'file' file_namespace_project_build_artifacts_path(*args) + when 'raw' + raw_namespace_project_build_artifacts_path(*args) end end diff --git a/app/models/ci/artifact_blob.rb b/app/models/ci/artifact_blob.rb new file mode 100644 index 00000000000..b35febc9ac5 --- /dev/null +++ b/app/models/ci/artifact_blob.rb @@ -0,0 +1,35 @@ +module Ci + class ArtifactBlob + include BlobLike + + attr_reader :entry + + def initialize(entry) + @entry = entry + end + + delegate :name, :path, to: :entry + + def id + Digest::SHA1.hexdigest(path) + end + + def size + entry.metadata[:size] + end + + def data + "Build artifact #{path}" + end + + def mode + entry.metadata[:mode] + end + + def external_storage + :build_artifact + end + + alias_method :external_size, :size + end +end diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index 36fb4c998c9..ce7e25d774b 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,9 +1,10 @@ - path_to_file = file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path: file.path) %tr.tree-item{ 'data-link' => path_to_file } + - blob = file.blob %td.tree-item-file-name - = tree_icon('file', '664', file.name) - %span.str-truncated - = link_to file.name, path_to_file + = tree_icon('file', blob.mode, blob.name) + = link_to path_to_file do + %span.str-truncated= blob.name %td - = number_to_human_size(file.metadata[:size], precision: 2) + = number_to_human_size(blob.size, precision: 2) diff --git a/app/views/projects/artifacts/file.html.haml b/app/views/projects/artifacts/file.html.haml new file mode 100644 index 00000000000..d8da83b9a80 --- /dev/null +++ b/app/views/projects/artifacts/file.html.haml @@ -0,0 +1,33 @@ +- page_title @path, 'Artifacts', "#{@build.name} (##{@build.id})", 'Jobs' += render "projects/pipelines/head" + += render "projects/builds/header", show_controls: false + +#tree-holder.tree-holder + .nav-block + %ul.breadcrumb.repo-breadcrumb + %li + = link_to 'Artifacts', browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build) + - path_breadcrumbs do |title, path| + - title = truncate(title, length: 40) + %li + - if path == @path + = link_to file_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) do + %strong= title + - else + = link_to title, browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build, path) + + + %article.file-holder + - blob = @entry.blob + .js-file-title.file-title-flex-parent + = render 'projects/blob/header_content', blob: blob + + .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob + + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) + = open_raw_blob_button(blob) + + = render 'projects/blob/content', blob: blob diff --git a/changelogs/unreleased/dm-artifact-blob-viewer.yml b/changelogs/unreleased/dm-artifact-blob-viewer.yml new file mode 100644 index 00000000000..38f5cbb73e1 --- /dev/null +++ b/changelogs/unreleased/dm-artifact-blob-viewer.yml @@ -0,0 +1,4 @@ +--- +title: Add artifact file page that uses the blob viewer +merge_request: +author: diff --git a/config/routes/project.rb b/config/routes/project.rb index a15e365cc2f..5aba469b6e4 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -183,6 +183,7 @@ constraints(ProjectUrlConstrainer.new) do get :download get :browse, path: 'browse(/*path)', format: false get :file, path: 'file/*path', format: false + get :raw, path: 'raw/*path', format: false post :keep end end diff --git a/features/project/builds/artifacts.feature b/features/project/builds/artifacts.feature index 09094d638c9..5abc24949cf 100644 --- a/features/project/builds/artifacts.feature +++ b/features/project/builds/artifacts.feature @@ -46,13 +46,14 @@ Feature: Project Builds Artifacts And I navigate to parent directory of directory with invalid name Then I should not see directory with invalid name on the list + @javascript Scenario: I download a single file from build artifacts Given recent build has artifacts available And recent build has artifacts metadata available When I visit recent build details page And I click artifacts browse button And I click a link to file within build artifacts - Then download of a file extracted from build artifacts should start + Then I see a download link @javascript Scenario: I click on a row in an artifacts table diff --git a/features/steps/project/builds/artifacts.rb b/features/steps/project/builds/artifacts.rb index 3ec5c8e2f4f..eec375b0532 100644 --- a/features/steps/project/builds/artifacts.rb +++ b/features/steps/project/builds/artifacts.rb @@ -3,6 +3,7 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps include SharedProject include SharedBuilds include RepoHelpers + include WaitForAjax step 'I click artifacts download button' do click_link 'Download' @@ -78,19 +79,11 @@ class Spinach::Features::ProjectBuildsArtifacts < Spinach::FeatureSteps step 'I click a link to file within build artifacts' do page.within('.tree-table') { find_link('ci_artifacts.txt').click } + wait_for_ajax end - step 'download of a file extracted from build artifacts should start' do - send_data = response_headers[Gitlab::Workhorse::SEND_DATA_HEADER] - - expect(send_data).to start_with('artifacts-entry:') - - base64_params = send_data.sub(/\Aartifacts\-entry:/, '') - params = JSON.parse(Base64.urlsafe_decode64(base64_params)) - - expect(params.keys).to eq(%w(Archive Entry)) - expect(params['Archive']).to end_with('build_artifacts.zip') - expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + step 'I see a download link' do + expect(page).to have_link 'download it' end step 'I click a first row within build artifacts table' do diff --git a/lib/gitlab/ci/build/artifacts/metadata/entry.rb b/lib/gitlab/ci/build/artifacts/metadata/entry.rb index 6f799c2f031..2e073334abc 100644 --- a/lib/gitlab/ci/build/artifacts/metadata/entry.rb +++ b/lib/gitlab/ci/build/artifacts/metadata/entry.rb @@ -37,6 +37,12 @@ module Gitlab !directory? end + def blob + return unless file? + + @blob ||= Blob.decorate(::Ci::ArtifactBlob.new(self), nil) + end + def has_parent? nodes > 0 end diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb new file mode 100644 index 00000000000..eff9fab8da2 --- /dev/null +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -0,0 +1,188 @@ +require 'spec_helper' + +describe Projects::ArtifactsController do + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + let(:pipeline) do + create(:ci_pipeline, + project: project, + sha: project.commit.sha, + ref: project.default_branch, + status: 'success') + end + + let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } + + before do + project.team << [user, :developer] + + sign_in(user) + end + + describe 'GET download' do + it 'sends the artifacts file' do + expect(controller).to receive(:send_file).with(build.artifacts_file.path, disposition: 'attachment').and_call_original + + get :download, namespace_id: project.namespace, project_id: project, build_id: build + end + end + + describe 'GET browse' do + context 'when the directory exists' do + it 'renders the browse view' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'other_artifacts_0.1.2' + + expect(response).to render_template('projects/artifacts/browse') + end + end + + context 'when the directory does not exist' do + it 'responds Not Found' do + get :browse, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET file' do + context 'when the file exists' do + it 'renders the file view' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + expect(response).to render_template('projects/artifacts/file') + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :file, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET raw' do + context 'when the file exists' do + it 'serves the file using workhorse' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'ci_artifacts.txt' + + send_data = response.headers[Gitlab::Workhorse::SEND_DATA_HEADER] + + expect(send_data).to start_with('artifacts-entry:') + + base64_params = send_data.sub(/\Aartifacts\-entry:/, '') + params = JSON.parse(Base64.urlsafe_decode64(base64_params)) + + expect(params.keys).to eq(%w(Archive Entry)) + expect(params['Archive']).to end_with('build_artifacts.zip') + expect(params['Entry']).to eq(Base64.encode64('ci_artifacts.txt')) + end + end + + context 'when the file does not exist' do + it 'responds Not Found' do + get :raw, namespace_id: project.namespace, project_id: project, build_id: build, path: 'unknown' + + expect(response).to be_not_found + end + end + end + + describe 'GET latest_succeeded' do + def params_from_ref(ref = pipeline.ref, job = build.name, path = 'browse') + { + namespace_id: project.namespace, + project_id: project, + ref_name_and_path: File.join(ref, path), + job: job + } + end + + context 'cannot find the build' do + shared_examples 'not found' do + it { expect(response).to have_http_status(:not_found) } + end + + context 'has no such ref' do + before do + get :latest_succeeded, params_from_ref('TAIL', build.name) + end + + it_behaves_like 'not found' + end + + context 'has no such build' do + before do + get :latest_succeeded, params_from_ref(pipeline.ref, 'NOBUILD') + end + + it_behaves_like 'not found' + end + + context 'has no path' do + before do + get :latest_succeeded, params_from_ref(pipeline.sha, build.name, '') + end + + it_behaves_like 'not found' + end + end + + context 'found the build and redirect' do + shared_examples 'redirect to the build' do + it 'redirects' do + path = browse_namespace_project_build_artifacts_path( + project.namespace, + project, + build) + + expect(response).to redirect_to(path) + end + end + + context 'with regular branch' do + before do + pipeline.update(ref: 'master', + sha: project.commit('master').sha) + + get :latest_succeeded, params_from_ref('master') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name containing slash' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome') + end + + it_behaves_like 'redirect to the build' + end + + context 'with branch name and path containing slashes' do + before do + pipeline.update(ref: 'improve/awesome', + sha: project.commit('improve/awesome').sha) + + get :latest_succeeded, params_from_ref('improve/awesome', build.name, 'file/README.md') + end + + it 'redirects' do + path = file_namespace_project_build_artifacts_path( + project.namespace, + project, + build, + 'README.md') + + expect(response).to redirect_to(path) + end + end + end + end +end diff --git a/spec/features/projects/artifacts/file_spec.rb b/spec/features/projects/artifacts/file_spec.rb new file mode 100644 index 00000000000..74308a7e8dd --- /dev/null +++ b/spec/features/projects/artifacts/file_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +feature 'Artifact file', :js, feature: true do + let(:project) { create(:project, :public) } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.sha, ref: 'master') } + let(:build) { create(:ci_build, :artifacts, pipeline: pipeline) } + + def visit_file(path) + visit file_namespace_project_build_artifacts_path(project.namespace, project, build, path) + end + + context 'Text file' do + before do + visit_file('other_artifacts_0.1.2/doc_sample.txt') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # shows an error message + expect(page).to have_content('The source could not be displayed because it is stored as a job artifact. You can download it instead.') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') + end + end + end + + context 'JPG file' do + before do + visit_file('rails_sample.jpg') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows rendered image + expect(page).to have_selector('.image_file img') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + + # shows a download button + expect(page).to have_link('Download') + end + end + end +end diff --git a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb index abc93e1b44a..3b905611467 100644 --- a/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb +++ b/spec/lib/gitlab/ci/build/artifacts/metadata/entry_spec.rb @@ -135,6 +135,17 @@ describe Gitlab::Ci::Build::Artifacts::Metadata::Entry do subject { |example| path(example).nodes } it { is_expected.to eq 4 } end + + describe '#blob' do + let(:file_entry) { |example| path(example) } + subject { file_entry.blob } + + it 'returns a blob representing the entry data' do + expect(subject).to be_a(Blob) + expect(subject.path).to eq(file_entry.path) + expect(subject.size).to eq(file_entry.metadata[:size]) + end + end end describe 'non-existent/', path: 'non-existent/' do diff --git a/spec/models/ci/artifact_blob_spec.rb b/spec/models/ci/artifact_blob_spec.rb new file mode 100644 index 00000000000..968593d7e9b --- /dev/null +++ b/spec/models/ci/artifact_blob_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Ci::ArtifactBlob, models: true do + let(:build) { create(:ci_build, :artifacts) } + let(:entry) { build.artifacts_metadata_entry('other_artifacts_0.1.2/another-subdirectory/banana_sample.gif') } + + subject { described_class.new(entry) } + + describe '#id' do + it 'returns a hash of the path' do + expect(subject.id).to eq(Digest::SHA1.hexdigest(entry.path)) + end + end + + describe '#name' do + it 'returns the entry name' do + expect(subject.name).to eq(entry.name) + end + end + + describe '#path' do + it 'returns the entry path' do + expect(subject.path).to eq(entry.path) + end + end + + describe '#size' do + it 'returns the entry size' do + expect(subject.size).to eq(entry.metadata[:size]) + end + end + + describe '#mode' do + it 'returns the entry mode' do + expect(subject.mode).to eq(entry.metadata[:mode]) + end + end + + describe '#external_storage' do + it 'returns :build_artifact' do + expect(subject.external_storage).to eq(:build_artifact) + end + end +end diff --git a/spec/requests/projects/artifacts_controller_spec.rb b/spec/requests/projects/artifacts_controller_spec.rb deleted file mode 100644 index d20866c0d44..00000000000 --- a/spec/requests/projects/artifacts_controller_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spec_helper' - -describe Projects::ArtifactsController do - let(:user) { create(:user) } - let(:project) { create(:project, :repository) } - - let(:pipeline) do - create(:ci_pipeline, - project: project, - sha: project.commit.sha, - ref: project.default_branch, - status: 'success') - end - - let(:build) { create(:ci_build, :success, :artifacts, pipeline: pipeline) } - - describe 'GET /:project/builds/artifacts/:ref_name/browse?job=name' do - before do - project.team << [user, :developer] - - login_as(user) - end - - def path_from_ref( - ref = pipeline.ref, job = build.name, path = 'browse') - latest_succeeded_namespace_project_artifacts_path( - project.namespace, - project, - [ref, path].join('/'), - job: job) - end - - context 'cannot find the build' do - shared_examples 'not found' do - it { expect(response).to have_http_status(:not_found) } - end - - context 'has no such ref' do - before do - get path_from_ref('TAIL', build.name) - end - - it_behaves_like 'not found' - end - - context 'has no such build' do - before do - get path_from_ref(pipeline.ref, 'NOBUILD') - end - - it_behaves_like 'not found' - end - - context 'has no path' do - before do - get path_from_ref(pipeline.sha, build.name, '') - end - - it_behaves_like 'not found' - end - end - - context 'found the build and redirect' do - shared_examples 'redirect to the build' do - it 'redirects' do - path = browse_namespace_project_build_artifacts_path( - project.namespace, - project, - build) - - expect(response).to redirect_to(path) - end - end - - context 'with regular branch' do - before do - pipeline.update(ref: 'master', - sha: project.commit('master').sha) - - get path_from_ref('master') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name containing slash' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome') - end - - it_behaves_like 'redirect to the build' - end - - context 'with branch name and path containing slashes' do - before do - pipeline.update(ref: 'improve/awesome', - sha: project.commit('improve/awesome').sha) - - get path_from_ref('improve/awesome', build.name, 'file/README.md') - end - - it 'redirects' do - path = file_namespace_project_build_artifacts_path( - project.namespace, - project, - build, - 'README.md') - - expect(response).to redirect_to(path) - end - end - end - end -end -- cgit v1.2.1