diff options
43 files changed, 2091 insertions, 242 deletions
diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 index f084340a4f8..54f13e328bd 100644 --- a/app/assets/javascripts/dispatcher.js.es6 +++ b/app/assets/javascripts/dispatcher.js.es6 @@ -184,11 +184,6 @@ new TreeView(); } break; - case 'projects:pipelines:index': - new gl.MiniPipelineGraph({ - container: '.js-pipeline-table', - }); - break; case 'projects:pipelines:builds': case 'projects:pipelines:show': const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js.es6 index 31a71379af3..b8d637a9827 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js.es6 @@ -139,6 +139,21 @@ }, 200); }; + /** + this will take in the `name` of the param you want to parse in the url + if the name does not exist this function will return `null` + otherwise it will return the value of the param key provided + */ + w.gl.utils.getParameterByName = (name) => { + const url = window.location.href; + name = name.replace(/[[\]]/g, '\\$&'); + const regex = new RegExp(`[?&]${name}(=([^&#]*)|&|#|$)`); + const results = regex.exec(url); + if (!results) return null; + if (!results[2]) return ''; + return decodeURIComponent(results[2].replace(/\+/g, ' ')); + }; + })(window); }).call(this); diff --git a/app/assets/javascripts/vue_pagination/index.js.es6 b/app/assets/javascripts/vue_pagination/index.js.es6 new file mode 100644 index 00000000000..605824fa939 --- /dev/null +++ b/app/assets/javascripts/vue_pagination/index.js.es6 @@ -0,0 +1,148 @@ +/* global Vue, gl */ +/* eslint-disable no-param-reassign, no-plusplus */ + +((gl) => { + const PAGINATION_UI_BUTTON_LIMIT = 4; + const UI_LIMIT = 6; + const SPREAD = '...'; + const PREV = 'Prev'; + const NEXT = 'Next'; + const FIRST = '<< First'; + const LAST = 'Last >>'; + + gl.VueGlPagination = Vue.extend({ + props: { + + /** + This function will take the information given by the pagination component + And make a new Turbolinks call + + Here is an example `change` method: + + change(pagenum, apiScope) { + Turbolinks.visit(`?scope=${apiScope}&p=${pagenum}`); + }, + */ + + change: { + type: Function, + required: true, + }, + + /** + pageInfo will come from the headers of the API call + in the `.then` clause of the VueResource API call + there should be a function that contructs the pageInfo for this component + + This is an example: + + const pageInfo = headers => ({ + perPage: +headers['X-Per-Page'], + page: +headers['X-Page'], + total: +headers['X-Total'], + totalPages: +headers['X-Total-Pages'], + nextPage: +headers['X-Next-Page'], + previousPage: +headers['X-Prev-Page'], + }); + */ + + pageInfo: { + type: Object, + required: true, + }, + }, + methods: { + changePage(e) { + let apiScope = gl.utils.getParameterByName('scope'); + + if (!apiScope) apiScope = 'all'; + + const text = e.target.innerText; + const { totalPages, nextPage, previousPage } = this.pageInfo; + + switch (text) { + case SPREAD: + break; + case LAST: + this.change(totalPages, apiScope); + break; + case NEXT: + this.change(nextPage, apiScope); + break; + case PREV: + this.change(previousPage, apiScope); + break; + case FIRST: + this.change(1, apiScope); + break; + default: + this.change(+text, apiScope); + break; + } + }, + }, + computed: { + prev() { + return this.pageInfo.previousPage; + }, + next() { + return this.pageInfo.nextPage; + }, + getItems() { + const total = this.pageInfo.totalPages; + const page = this.pageInfo.page; + const items = []; + + if (page > 1) items.push({ title: FIRST }); + + if (page > 1) { + items.push({ title: PREV, prev: true }); + } else { + items.push({ title: PREV, disabled: true, prev: true }); + } + + if (page > UI_LIMIT) items.push({ title: SPREAD, separator: true }); + + const start = Math.max(page - PAGINATION_UI_BUTTON_LIMIT, 1); + const end = Math.min(page + PAGINATION_UI_BUTTON_LIMIT, total); + + for (let i = start; i <= end; i++) { + const isActive = i === page; + items.push({ title: i, active: isActive, page: true }); + } + + if (total - page > PAGINATION_UI_BUTTON_LIMIT) { + items.push({ title: SPREAD, separator: true, page: true }); + } + + if (page === total) { + items.push({ title: NEXT, disabled: true, next: true }); + } else if (total - page >= 1) { + items.push({ title: NEXT, next: true }); + } + + if (total - page >= 1) items.push({ title: LAST, last: true }); + + return items; + }, + }, + template: ` + <div class="gl-pagination"> + <ul class="pagination clearfix"> + <li v-for='item in getItems' + :class='{ + page: item.page, + prev: item.prev, + next: item.next, + separator: item.separator, + active: item.active, + disabled: item.disabled + }' + > + <a @click="changePage($event)">{{item.title}}</a> + </li> + </ul> + </div> + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/index.js.es6 b/app/assets/javascripts/vue_pipelines_index/index.js.es6 new file mode 100644 index 00000000000..9dfbedd73ab --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/index.js.es6 @@ -0,0 +1,41 @@ +/* global Vue, VueResource, gl */ +/*= require vue_common_component/commit */ +/*= require vue-resource +/*= require boards/vue_resource_interceptor */ +/*= require ./status.js.es6 */ +/*= require ./store.js.es6 */ +/*= require ./pipeline_url.js.es6 */ +/*= require ./stage.js.es6 */ +/*= require ./stages.js.es6 */ +/*= require ./pipeline_actions.js.es6 */ +/*= require ./time_ago.js.es6 */ +/*= require ./pipelines.js.es6 */ + +(() => { + const project = document.querySelector('.pipelines'); + const entry = document.querySelector('.vue-pipelines-index'); + const svgs = document.querySelector('.pipeline-svgs'); + + Vue.use(VueResource); + + if (!entry) return null; + return new Vue({ + el: entry, + data: { + scope: project.dataset.url, + store: new gl.PipelineStore(), + svgs: svgs.dataset, + }, + components: { + 'vue-pipelines': gl.VuePipelines, + }, + template: ` + <vue-pipelines + :scope='scope' + :store='store' + :svgs='svgs' + > + </vue-pipelines> + `, + }); +})(); diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 new file mode 100644 index 00000000000..ad5cb30cc42 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 @@ -0,0 +1,99 @@ +/* global Vue, Flash, gl */ +/* eslint-disable no-param-reassign */ + +((gl) => { + gl.VuePipelineActions = Vue.extend({ + props: ['pipeline', 'svgs'], + computed: { + actions() { + return this.pipeline.details.manual_actions.length > 0; + }, + artifacts() { + return this.pipeline.details.artifacts.length > 0; + }, + }, + methods: { + download(name) { + return `Download ${name} artifacts`; + }, + }, + template: ` + <td class="pipeline-actions hidden-xs"> + <div class="controls pull-right"> + <div class="btn-group inline"> + <div class="btn-group"> + <a + v-if='actions' + class="dropdown-toggle btn btn-default js-pipeline-dropdown-manual-actions" + data-toggle="dropdown" + title="Manual build" + alt="Manual Build" + > + <span v-html='svgs.iconPlay'></span> + <i class="fa fa-caret-down"></i> + </a> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for='action in pipeline.details.manual_actions'> + <a + rel="nofollow" + data-method="post" + :href='action.path' + title="Manual build" + > + <span v-html='svgs.iconPlay'></span> + <span title="Manual build">{{action.name}}</span> + </a> + </li> + </ul> + </div> + <div class="btn-group"> + <a + v-if='artifacts' + class="dropdown-toggle btn btn-default build-artifacts js-pipeline-dropdown-download" + data-toggle="dropdown" + type="button" + > + <i class="fa fa-download"></i> + <i class="fa fa-caret-down"></i> + </a> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for='artifact in pipeline.details.artifacts'> + <a + rel="nofollow" + :href='artifact.path' + > + <i class="fa fa-download"></i> + <span>{{download(artifact.name)}}</span> + </a> + </li> + </ul> + </div> + </div> + <div class="cancel-retry-btns inline"> + <a + v-if='pipeline.flags.retryable' + class="btn has-tooltip" + title="Retry" + rel="nofollow" + data-method="post" + :href='pipeline.retry_path' + > + <i class="fa fa-repeat"></i> + </a> + <a + v-if='pipeline.flags.cancelable' + class="btn btn-remove has-tooltip" + title="Cancel" + rel="nofollow" + data-method="post" + :href='pipeline.cancel_path' + data-original-title="Cancel" + > + <i class="fa fa-remove"></i> + </a> + </div> + </div> + </td> + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_url.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipeline_url.js.es6 new file mode 100644 index 00000000000..ae5649f0519 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/pipeline_url.js.es6 @@ -0,0 +1,63 @@ +/* global Vue, gl */ +/* eslint-disable no-param-reassign */ + +((gl) => { + gl.VuePipelineUrl = Vue.extend({ + props: [ + 'pipeline', + ], + computed: { + user() { + return !!this.pipeline.user; + }, + }, + template: ` + <td> + <a :href='pipeline.path'> + <span class="pipeline-id">#{{pipeline.id}}</span> + </a> + <span>by</span> + <a + v-if='user' + :href='pipeline.user.web_url' + > + <img + v-if='user' + class="avatar has-tooltip s20 " + :title='pipeline.user.name' + data-container="body" + :src='pipeline.user.avatar_url' + > + </a> + <span + v-if='!user' + class="api monospace" + > + API + </span> + <span + v-if='pipeline.flags.latest' + class="label label-success has-tooltip" + title="Latest pipeline for this branch" + data-original-title="Latest pipeline for this branch" + > + latest + </span> + <span + v-if='pipeline.flags.yaml_errors' + class="label label-danger has-tooltip" + :title='pipeline.yaml_errors' + :data-original-title='pipeline.yaml_errors' + > + yaml invalid + </span> + <span + v-if='pipeline.flags.stuck' + class="label label-warning" + > + stuck + </span> + </td> + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 new file mode 100644 index 00000000000..73627e9ba50 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 @@ -0,0 +1,131 @@ +/* global Vue, Turbolinks, gl */ +/* eslint-disable no-param-reassign */ + +((gl) => { + gl.VuePipelines = Vue.extend({ + components: { + runningPipeline: gl.VueRunningPipeline, + pipelineActions: gl.VuePipelineActions, + stages: gl.VueStages, + commit: gl.CommitComponent, + pipelineUrl: gl.VuePipelineUrl, + pipelineHead: gl.VuePipelineHead, + glPagination: gl.VueGlPagination, + statusScope: gl.VueStatusScope, + timeAgo: gl.VueTimeAgo, + }, + data() { + return { + pipelines: [], + timeLoopInterval: '', + intervalId: '', + apiScope: 'all', + pageInfo: {}, + pagenum: 1, + count: { all: 0, running_or_pending: 0 }, + pageRequest: false, + }; + }, + props: ['scope', 'store', 'svgs'], + created() { + const pagenum = gl.utils.getParameterByName('p'); + const scope = gl.utils.getParameterByName('scope'); + if (pagenum) this.pagenum = pagenum; + if (scope) this.apiScope = scope; + this.store.fetchDataLoop.call(this, Vue, this.pagenum, this.scope, this.apiScope); + }, + methods: { + change(pagenum, apiScope) { + Turbolinks.visit(`?scope=${apiScope}&p=${pagenum}`); + }, + author(pipeline) { + if (!pipeline.commit) return { avatar_url: '', web_url: '', username: '' }; + if (pipeline.commit.author) return pipeline.commit.author; + return { + avatar_url: pipeline.commit.author_gravatar_url, + web_url: `mailto:${pipeline.commit.author_email}`, + username: pipeline.commit.author_name, + }; + }, + ref(pipeline) { + const { ref } = pipeline; + return { name: ref.name, tag: ref.tag, ref_url: ref.path }; + }, + commitTitle(pipeline) { + return pipeline.commit ? pipeline.commit.title : ''; + }, + commitSha(pipeline) { + return pipeline.commit ? pipeline.commit.short_id : ''; + }, + commitUrl(pipeline) { + return pipeline.commit ? pipeline.commit.commit_path : ''; + }, + match(string) { + return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase()); + }, + }, + template: ` + <div> + <div class="pipelines realtime-loading" v-if='pipelines.length < 1'> + <i class="fa fa-spinner fa-spin"></i> + </div> + <div class="table-holder" v-if='pipelines.length'> + <table class="table ci-table"> + <thead> + <tr> + <th>Status</th> + <th>Pipeline</th> + <th>Commit</th> + <th>Stages</th> + <th></th> + <th class="hidden-xs"></th> + </tr> + </thead> + <tbody> + <tr class="commit" v-for='pipeline in pipelines'> + <status-scope + :pipeline='pipeline' + :match='match' + :svgs='svgs' + > + </status-scope> + <pipeline-url :pipeline='pipeline'></pipeline-url> + <td> + <commit + :commit-icon-svg='svgs.commitIconSvg' + :author='author(pipeline)' + :tag="pipeline.ref.tag" + :title='commitTitle(pipeline)' + :commit-ref='ref(pipeline)' + :short-sha='commitSha(pipeline)' + :commit-url='commitUrl(pipeline)' + > + </commit> + </td> + <stages + :pipeline='pipeline' + :svgs='svgs' + :match='match' + > + </stages> + <time-ago :pipeline='pipeline' :svgs='svgs'></time-ago> + <pipeline-actions :pipeline='pipeline' :svgs='svgs'></pipeline-actions> + </tr> + </tbody> + </table> + </div> + <div class="pipelines realtime-loading" v-if='pageRequest'> + <i class="fa fa-spinner fa-spin"></i> + </div> + <gl-pagination + v-if='pageInfo.total > pageInfo.perPage' + :pagenum='pagenum' + :change='change' + :count='count.all' + :pageInfo='pageInfo' + > + </gl-pagination> + </div> + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/stage.js.es6 b/app/assets/javascripts/vue_pipelines_index/stage.js.es6 new file mode 100644 index 00000000000..74a79dcedae --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/stage.js.es6 @@ -0,0 +1,76 @@ +/* global Vue, Flash, gl */ +/* eslint-disable no-param-reassign */ + +((gl) => { + gl.VueStage = Vue.extend({ + data() { + return { + request: false, + builds: '', + spinner: '<span class="fa fa-spinner fa-spin"></span>', + }; + }, + props: ['stage', 'svgs', 'match'], + methods: { + fetchBuilds() { + if (this.request) return this.clearBuilds(); + + return this.$http.get(this.stage.dropdown_path) + .then((response) => { + this.request = true; + this.builds = JSON.parse(response.body).html; + }, () => { + const flash = new Flash('Something went wrong on our end.'); + this.request = false; + return flash; + }); + }, + clearBuilds() { + this.builds = ''; + this.request = false; + }, + }, + computed: { + buildsOrSpinner() { + return this.request ? this.builds : this.spinner; + }, + dropdownClass() { + if (this.request) return 'js-builds-dropdown-container'; + return 'js-builds-dropdown-loading builds-dropdown-loading'; + }, + buildStatus() { + return `Build: ${this.stage.status.label}`; + }, + tooltip() { + return `has-tooltip ci-status-icon ci-status-icon-${this.stage.status.group}`; + }, + svg() { + const icon = this.stage.status.icon; + const stageIcon = icon.replace(/icon/i, 'stage_icon'); + return this.svgs[this.match(stageIcon)]; + }, + triggerButtonClass() { + return `mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button ci-status-icon-${this.stage.status.group}`; + }, + }, + template: ` + <div> + <button + @click='fetchBuilds' + @blur='fetchBuilds' + :class="triggerButtonClass" + :title='stage.title' + data-placement="top" + data-toggle="dropdown" + type="button"> + <span v-html="svg"></span> + <i class="fa fa-caret-down "></i> + </button> + <ul class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container"> + <div class="arrow-up"></div> + <div :class="dropdownClass" class="js-builds-dropdown-list scrollable-menu" v-html="buildsOrSpinner"></div> + </ul> + </div> + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/stages.js.es6 b/app/assets/javascripts/vue_pipelines_index/stages.js.es6 new file mode 100644 index 00000000000..cb176b3f0c6 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/stages.js.es6 @@ -0,0 +1,21 @@ +/* global Vue, gl */ +/* eslint-disable no-param-reassign */ + +((gl) => { + gl.VueStages = Vue.extend({ + components: { + 'vue-stage': gl.VueStage, + }, + props: ['pipeline', 'svgs', 'match'], + template: ` + <td class="stage-cell"> + <div + class="stage-container dropdown js-mini-pipeline-graph" + v-for='stage in pipeline.details.stages' + > + <vue-stage :stage='stage' :svgs='svgs' :match='match'></vue-stage> + </div> + </td> + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/status.js.es6 b/app/assets/javascripts/vue_pipelines_index/status.js.es6 new file mode 100644 index 00000000000..05175082704 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/status.js.es6 @@ -0,0 +1,34 @@ +/* global Vue, gl */ +/* eslint-disable no-param-reassign */ + +((gl) => { + gl.VueStatusScope = Vue.extend({ + props: [ + 'pipeline', 'svgs', 'match', + ], + computed: { + cssClasses() { + const cssObject = { 'ci-status': true }; + cssObject[`ci-${this.pipeline.details.status.group}`] = true; + return cssObject; + }, + svg() { + return this.svgs[this.match(this.pipeline.details.status.icon)]; + }, + detailsPath() { + const { status } = this.pipeline.details; + return status.has_details ? status.details_path : false; + }, + }, + template: ` + <td class="commit-link"> + <a + :class='cssClasses' + :href='detailsPath' + v-html='svg + pipeline.details.status.text' + > + </a> + </td> + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/store.js.es6 b/app/assets/javascripts/vue_pipelines_index/store.js.es6 new file mode 100644 index 00000000000..6b34839b030 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/store.js.es6 @@ -0,0 +1,59 @@ +/* global gl, Flash */ +/* eslint-disable no-param-reassign, no-underscore-dangle */ +/*= require vue_realtime_listener/index.js */ + +((gl) => { + const pageValues = headers => ({ + perPage: +headers['X-Per-Page'], + page: +headers['X-Page'], + total: +headers['X-Total'], + totalPages: +headers['X-Total-Pages'], + nextPage: +headers['X-Next-Page'], + previousPage: +headers['X-Prev-Page'], + }); + + gl.PipelineStore = class { + fetchDataLoop(Vue, pageNum, url, apiScope) { + const updatePipelineNums = (count) => { + const { all } = count; + const running = count.running_or_pending; + document.querySelector('.js-totalbuilds-count').innerHTML = all; + document.querySelector('.js-running-count').innerHTML = running; + }; + + const goFetch = () => + this.$http.get(`${url}?scope=${apiScope}&page=${pageNum}`) + .then((response) => { + const pageInfo = pageValues(response.headers); + this.pageInfo = Object.assign({}, this.pageInfo, pageInfo); + + const res = JSON.parse(response.body); + this.count = Object.assign({}, this.count, res.count); + this.pipelines = Object.assign([], this.pipelines, res.pipelines); + + updatePipelineNums(this.count); + this.pageRequest = false; + }, () => { + this.pageRequest = false; + return new Flash('Something went wrong on our end.'); + }); + + goFetch(); + + const startTimeLoops = () => { + this.timeLoopInterval = setInterval(() => { + this.$children + .filter(e => e.$options._componentTag === 'time-ago') + .forEach(e => e.changeTime()); + }, 10000); + }; + + startTimeLoops(); + + const removeIntervals = () => clearInterval(this.timeLoopInterval); + const startIntervals = () => startTimeLoops(); + + gl.VueRealtimeListener(removeIntervals, startIntervals); + } + }; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 new file mode 100644 index 00000000000..655110feba1 --- /dev/null +++ b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 @@ -0,0 +1,73 @@ +/* global Vue, gl */ +/* eslint-disable no-param-reassign */ + +((gl) => { + gl.VueTimeAgo = Vue.extend({ + data() { + return { + currentTime: new Date(), + }; + }, + props: ['pipeline', 'svgs'], + computed: { + timeAgo() { + return gl.utils.getTimeago(); + }, + localTimeFinished() { + return gl.utils.formatDate(this.pipeline.details.finished_at); + }, + timeStopped() { + const changeTime = this.currentTime; + const options = { + weekday: 'long', + year: 'numeric', + month: 'short', + day: 'numeric', + }; + options.timeZoneName = 'short'; + const finished = this.pipeline.details.finished_at; + if (!finished && changeTime) return false; + return ({ words: this.timeAgo.format(finished) }); + }, + duration() { + const { duration } = this.pipeline.details; + const date = new Date(duration * 1000); + + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); + + if (hh < 10) hh = `0${hh}`; + if (mm < 10) mm = `0${mm}`; + if (ss < 10) ss = `0${ss}`; + + if (duration !== null) return `${hh}:${mm}:${ss}`; + return false; + }, + }, + methods: { + changeTime() { + this.currentTime = new Date(); + }, + }, + template: ` + <td> + <p class="duration" v-if='duration'> + <span v-html='svgs.iconTimer'></span> + {{duration}} + </p> + <p class="finished-at" v-if='timeStopped'> + <i class="fa fa-calendar"></i> + <time + data-toggle="tooltip" + data-placement="top" + data-container="body" + :data-original-title='localTimeFinished' + > + {{timeStopped.words}} + </time> + </p> + </td> + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_realtime_listener/index.js.es6 b/app/assets/javascripts/vue_realtime_listener/index.js.es6 new file mode 100644 index 00000000000..23cac1466d2 --- /dev/null +++ b/app/assets/javascripts/vue_realtime_listener/index.js.es6 @@ -0,0 +1,18 @@ +/* eslint-disable no-param-reassign */ + +((gl) => { + gl.VueRealtimeListener = (removeIntervals, startIntervals) => { + const removeAll = () => { + removeIntervals(); + window.removeEventListener('beforeunload', removeIntervals); + window.removeEventListener('focus', startIntervals); + window.removeEventListener('blur', removeIntervals); + document.removeEventListener('page:fetch', removeAll); + }; + + window.addEventListener('beforeunload', removeIntervals); + window.addEventListener('focus', startIntervals); + window.addEventListener('blur', removeIntervals); + document.addEventListener('page:fetch', removeAll); + }; +})(window.gl || (window.gl = {})); diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index ed53ad94021..8861315d776 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -1,4 +1,9 @@ .pipelines { + .realtime-loading { + font-size: 40px; + text-align: center; + } + .stage { max-width: 90px; width: 90px; @@ -24,6 +29,10 @@ min-width: 1200px; table-layout: fixed; + .label { + margin-bottom: 3px; + } + .pipeline-id { color: $black; } @@ -177,6 +186,7 @@ .stage-cell { font-size: 0; + > .stage-container > div > button > span > svg, > .stage-container > button > svg { height: 22px; width: 22px; diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index cc347922c6a..84451257b98 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -7,11 +7,33 @@ class Projects::PipelinesController < Projects::ApplicationController def index @scope = params[:scope] - @pipelines = PipelinesFinder.new(project).execute(scope: @scope).page(params[:page]).per(30) - @pipelines = @pipelines.includes(project: :namespace) + @pipelines = PipelinesFinder + .new(project) + .execute(scope: @scope) + .page(params[:page]) + .per(30) - @running_or_pending_count = PipelinesFinder.new(project).execute(scope: 'running').count - @pipelines_count = PipelinesFinder.new(project).execute.count + @running_or_pending_count = PipelinesFinder + .new(project).execute(scope: 'running').count + + @pipelines_count = PipelinesFinder + .new(project).execute.count + + respond_to do |format| + format.html + format.json do + render json: { + pipelines: PipelineSerializer + .new(project: @project, user: @current_user) + .with_pagination(request, response) + .represent(@pipelines), + count: { + all: @pipelines_count, + running_or_pending: @running_or_pending_count + } + } + end + end end def new diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index abbbddaa4f6..2a97e8bae4a 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -142,7 +142,7 @@ module Ci end def artifacts - builds.latest.with_artifacts_not_expired + builds.latest.with_artifacts_not_expired.includes(project: [:namespace]) end def project_id @@ -191,7 +191,11 @@ module Ci end def manual_actions - builds.latest.manual_actions + builds.latest.manual_actions.includes(project: [:namespace]) + end + + def stuck? + builds.pending.any?(&:stuck?) end def retryable? @@ -283,6 +287,10 @@ module Ci end end + def has_yaml_errors? + yaml_errors.present? + end + def environments builds.where.not(environment: nil).success.pluck(:environment).uniq end diff --git a/app/serializers/build_action_entity.rb b/app/serializers/build_action_entity.rb new file mode 100644 index 00000000000..3e72892d584 --- /dev/null +++ b/app/serializers/build_action_entity.rb @@ -0,0 +1,14 @@ +class BuildActionEntity < Grape::Entity + include RequestAwareEntity + + expose :name do |build| + build.name.humanize + end + + expose :path do |build| + play_namespace_project_build_path( + build.project.namespace, + build.project, + build) + end +end diff --git a/app/serializers/build_artifact_entity.rb b/app/serializers/build_artifact_entity.rb new file mode 100644 index 00000000000..8b643d8e783 --- /dev/null +++ b/app/serializers/build_artifact_entity.rb @@ -0,0 +1,14 @@ +class BuildArtifactEntity < Grape::Entity + include RequestAwareEntity + + expose :name do |build| + build.name + end + + expose :path do |build| + download_namespace_project_build_artifacts_path( + build.project.namespace, + build.project, + build) + end +end diff --git a/app/serializers/commit_entity.rb b/app/serializers/commit_entity.rb index acc20f6dc52..49f4db36295 100644 --- a/app/serializers/commit_entity.rb +++ b/app/serializers/commit_entity.rb @@ -3,6 +3,10 @@ class CommitEntity < API::Entities::RepoCommit expose :author, using: UserEntity + expose :author_gravatar_url do |commit| + GravatarService.new.execute(commit.author_email) + end + expose :commit_url do |commit| namespace_project_tree_url( request.project.namespace, diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb new file mode 100644 index 00000000000..d04a4990cb0 --- /dev/null +++ b/app/serializers/pipeline_entity.rb @@ -0,0 +1,83 @@ +class PipelineEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :user, using: UserEntity + + expose :path do |pipeline| + namespace_project_pipeline_path( + pipeline.project.namespace, + pipeline.project, + pipeline) + end + + expose :details do + expose :status do |pipeline, options| + StatusEntity.represent( + pipeline.detailed_status(request.user), + options) + end + + expose :duration + expose :finished_at + expose :stages, using: StageEntity + expose :artifacts, using: BuildArtifactEntity + expose :manual_actions, using: BuildActionEntity + end + + expose :flags do + expose :latest?, as: :latest + expose :triggered?, as: :triggered + expose :stuck?, as: :stuck + expose :has_yaml_errors?, as: :yaml_errors + expose :can_retry?, as: :retryable + expose :can_cancel?, as: :cancelable + end + + expose :ref do + expose :name do |pipeline| + pipeline.ref + end + + expose :path do |pipeline| + namespace_project_tree_path( + pipeline.project.namespace, + pipeline.project, + id: pipeline.ref) + end + + expose :tag?, as: :tag + expose :branch?, as: :branch + end + + expose :commit, using: CommitEntity + expose :yaml_errors, if: ->(pipeline, _) { pipeline.has_yaml_errors? } + + expose :retry_path, if: proc { can_retry? } do |pipeline| + retry_namespace_project_pipeline_path(pipeline.project.namespace, + pipeline.project, + pipeline.id) + end + + expose :cancel_path, if: proc { can_cancel? } do |pipeline| + cancel_namespace_project_pipeline_path(pipeline.project.namespace, + pipeline.project, + pipeline.id) + end + + expose :created_at, :updated_at + + private + + alias_method :pipeline, :object + + def can_retry? + pipeline.retryable? && + can?(request.user, :update_pipeline, pipeline) + end + + def can_cancel? + pipeline.cancelable? && + can?(request.user, :update_pipeline, pipeline) + end +end diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb new file mode 100644 index 00000000000..cfa86cc2553 --- /dev/null +++ b/app/serializers/pipeline_serializer.rb @@ -0,0 +1,40 @@ +class PipelineSerializer < BaseSerializer + entity PipelineEntity + class InvalidResourceError < StandardError; end + include API::Helpers::Pagination + Struct.new('Pagination', :request, :response) + + def represent(resource, opts = {}) + if paginated? + raise InvalidResourceError unless resource.respond_to?(:page) + + super(paginate(resource.includes(project: :namespace)), opts) + else + super(resource, opts) + end + end + + def paginated? + defined?(@pagination) + end + + def with_pagination(request, response) + tap { @pagination = Struct::Pagination.new(request, response) } + end + + private + + # Methods needed by `API::Helpers::Pagination` + # + def params + @pagination.request.query_parameters + end + + def request + @pagination.request + end + + def header(header, value) + @pagination.response.headers[header] = value + end +end diff --git a/app/serializers/request_aware_entity.rb b/app/serializers/request_aware_entity.rb index e159d750cb7..3039014aaaa 100644 --- a/app/serializers/request_aware_entity.rb +++ b/app/serializers/request_aware_entity.rb @@ -2,14 +2,11 @@ module RequestAwareEntity extend ActiveSupport::Concern included do - include Gitlab::Routing.url_helpers + include Gitlab::Routing + include Gitlab::Allowable end def request - @options.fetch(:request) - end - - def can?(object, action, subject) - Ability.allowed?(object, action, subject) + options.fetch(:request) end end diff --git a/app/serializers/stage_entity.rb b/app/serializers/stage_entity.rb new file mode 100644 index 00000000000..7a047bdc712 --- /dev/null +++ b/app/serializers/stage_entity.rb @@ -0,0 +1,38 @@ +class StageEntity < Grape::Entity + include RequestAwareEntity + + expose :name + + expose :title do |stage| + "#{stage.name}: #{detailed_status.label}" + end + + expose :detailed_status, + as: :status, + with: StatusEntity + + expose :path do |stage| + namespace_project_pipeline_path( + stage.pipeline.project.namespace, + stage.pipeline.project, + stage.pipeline, + anchor: stage.name) + end + + expose :dropdown_path do |stage| + stage_namespace_project_pipeline_path( + stage.pipeline.project.namespace, + stage.pipeline.project, + stage.pipeline, + stage: stage.name, + format: :json) + end + + private + + alias_method :stage, :object + + def detailed_status + stage.detailed_status(request.user) + end +end diff --git a/app/serializers/status_entity.rb b/app/serializers/status_entity.rb new file mode 100644 index 00000000000..47066bebfb1 --- /dev/null +++ b/app/serializers/status_entity.rb @@ -0,0 +1,8 @@ +class StatusEntity < Grape::Entity + include RequestAwareEntity + + expose :icon, :text, :label, :group + + expose :has_details?, as: :has_details + expose :details_path +end diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index 4bb3d4d35fb..abea6932567 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -35,21 +35,34 @@ = link_to ci_lint_path, class: 'btn btn-default' do %span CI Lint - - .content-list.pipelines + .content-list.pipelines{ data: { url: namespace_project_pipelines_path(@project.namespace, @project, format: :json) } } - if @pipelines.blank? %div .nothing-here-block No pipelines to show - else - .table-holder - %table.table.ci-table.js-pipeline-table - %thead - %th.pipeline-status Status - %th.pipeline-info Pipeline - %th.pipeline-commit Commit - %th.pipeline-stages Stages - %th.pipeline-date - %th.pipeline-actions.hidden-xs - = render @pipelines, commit_sha: true, stage: true, allow_retry: true - - = paginate @pipelines, theme: 'gitlab' + .pipeline-svgs{ "data" => {"commit_icon_svg" => custom_icon("icon_commit"), + "icon_status_canceled" => custom_icon("icon_status_canceled"), + "icon_status_running" => custom_icon("icon_status_running"), + "icon_status_skipped" => custom_icon("icon_status_skipped"), + "icon_status_created" => custom_icon("icon_status_created"), + "icon_status_pending" => custom_icon("icon_status_pending"), + "icon_status_success" => custom_icon("icon_status_success"), + "icon_status_failed" => custom_icon("icon_status_failed"), + "icon_status_warning" => custom_icon("icon_status_warning"), + "stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"), + "stage_icon_status_running" => custom_icon("icon_status_running_borderless"), + "stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"), + "stage_icon_status_created" => custom_icon("icon_status_created_borderless"), + "stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"), + "stage_icon_status_success" => custom_icon("icon_status_success_borderless"), + "stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"), + "stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"), + "icon_play" => custom_icon("icon_play"), + "icon_timer" => custom_icon("icon_timer"), + "icon_status_manual" => custom_icon("icon_status_manual"), + } } + + .vue-pipelines-index + += page_specific_javascript_tag('vue_pagination/index.js') += page_specific_javascript_tag('vue_pipelines_index/index.js') diff --git a/config/application.rb b/config/application.rb index d36c6d5c92e..1de7fb7bdb8 100644 --- a/config/application.rb +++ b/config/application.rb @@ -109,6 +109,8 @@ module Gitlab config.assets.precompile << "lib/utils/*.js" config.assets.precompile << "lib/*.js" config.assets.precompile << "u2f.js" + config.assets.precompile << "vue_pipelines_index/index.js" + config.assets.precompile << "vue_pagination/index.js" config.assets.precompile << "vendor/assets/fonts/*" # Version of your assets, change this if you want to expire all your assets diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index ee9247ee240..20b5bc1502a 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -1,6 +1,7 @@ module API module Helpers include Gitlab::Utils + include Helpers::Pagination SUDO_HEADER = "HTTP_SUDO" SUDO_PARAM = :sudo @@ -85,12 +86,6 @@ module API IssuesFinder.new(current_user, project_id: user_project.id).find(id) end - def paginate(relation) - relation.page(params[:page]).per(params[:per_page].to_i).tap do |data| - add_pagination_headers(data) - end - end - def authenticate! unauthorized! unless current_user end @@ -361,38 +356,6 @@ module API @sudo_identifier ||= params[SUDO_PARAM] || env[SUDO_HEADER] end - def add_pagination_headers(paginated_data) - header 'X-Total', paginated_data.total_count.to_s - header 'X-Total-Pages', paginated_data.total_pages.to_s - header 'X-Per-Page', paginated_data.limit_value.to_s - header 'X-Page', paginated_data.current_page.to_s - header 'X-Next-Page', paginated_data.next_page.to_s - header 'X-Prev-Page', paginated_data.prev_page.to_s - header 'Link', pagination_links(paginated_data) - end - - def pagination_links(paginated_data) - request_url = request.url.split('?').first - request_params = params.clone - request_params[:per_page] = paginated_data.limit_value - - links = [] - - request_params[:page] = paginated_data.current_page - 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page? - - request_params[:page] = paginated_data.current_page + 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page? - - request_params[:page] = 1 - links << %(<#{request_url}?#{request_params.to_query}>; rel="first") - - request_params[:page] = paginated_data.total_pages - links << %(<#{request_url}?#{request_params.to_query}>; rel="last") - - links.join(', ') - end - def secret_token Gitlab::Shell.secret_token end diff --git a/lib/api/helpers/pagination.rb b/lib/api/helpers/pagination.rb new file mode 100644 index 00000000000..2199eea7e5f --- /dev/null +++ b/lib/api/helpers/pagination.rb @@ -0,0 +1,45 @@ +module API + module Helpers + module Pagination + def paginate(relation) + relation.page(params[:page]).per(params[:per_page].to_i).tap do |data| + add_pagination_headers(data) + end + end + + private + + def add_pagination_headers(paginated_data) + header 'X-Total', paginated_data.total_count.to_s + header 'X-Total-Pages', paginated_data.total_pages.to_s + header 'X-Per-Page', paginated_data.limit_value.to_s + header 'X-Page', paginated_data.current_page.to_s + header 'X-Next-Page', paginated_data.next_page.to_s + header 'X-Prev-Page', paginated_data.prev_page.to_s + header 'Link', pagination_links(paginated_data) + end + + def pagination_links(paginated_data) + request_url = request.url.split('?').first + request_params = params.clone + request_params[:per_page] = paginated_data.limit_value + + links = [] + + request_params[:page] = paginated_data.current_page - 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="prev") unless paginated_data.first_page? + + request_params[:page] = paginated_data.current_page + 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="next") unless paginated_data.last_page? + + request_params[:page] = 1 + links << %(<#{request_url}?#{request_params.to_query}>; rel="first") + + request_params[:page] = paginated_data.total_pages + links << %(<#{request_url}?#{request_params.to_query}>; rel="last") + + links.join(', ') + end + end + end +end diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 5fe7e6407cc..1ed2ee3ab4a 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -5,13 +5,33 @@ describe Projects::PipelinesController do let(:user) { create(:user) } let(:project) { create(:empty_project, :public) } - let(:pipeline) { create(:ci_pipeline, project: project) } before do sign_in(user) end + describe 'GET index.json' do + before do + create_list(:ci_empty_pipeline, 2, project: project) + + get :index, namespace_id: project.namespace.path, + project_id: project.path, + format: :json + end + + it 'returns JSON with serialized pipelines' do + expect(response).to have_http_status(:ok) + + expect(json_response).to include('pipelines') + expect(json_response['pipelines'].count).to eq 2 + expect(json_response['count']['all']).to eq 2 + expect(json_response['count']['running_or_pending']).to eq 2 + end + end + describe 'GET stages.json' do + let(:pipeline) { create(:ci_pipeline, project: project) } + context 'when accessing existing stage' do before do create(:ci_build, pipeline: pipeline, stage: 'build') diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb index 1735791f644..77404f46c92 100644 --- a/spec/factories/ci/pipelines.rb +++ b/spec/factories/ci/pipelines.rb @@ -31,6 +31,14 @@ FactoryGirl.define do File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) end end + + # Populates pipeline with errors + # + pipeline.config_processor if evaluator.config + end + + trait :invalid do + config(rspec: nil) end end end diff --git a/spec/factories/ci/runners.rb b/spec/factories/ci/runners.rb index e3b73e29987..ed4acca23f1 100644 --- a/spec/factories/ci/runners.rb +++ b/spec/factories/ci/runners.rb @@ -8,6 +8,10 @@ FactoryGirl.define do is_shared false active true + trait :online do + contacted_at Time.now + end + trait :shared do is_shared true end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index cef50f6f237..3ba996e2e10 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -1,267 +1,364 @@ require 'spec_helper' describe 'Pipelines', :feature, :js do - include GitlabRoutingHelper - include WaitForAjax + include WaitForVueResource let(:project) { create(:empty_project) } - let(:user) { create(:user) } - before do - login_as(user) - project.team << [user, :developer] - end - - describe 'GET /:project/pipelines' do - let!(:pipeline) { create(:ci_empty_pipeline, project: project, ref: 'master', status: 'running') } - - [:all, :running, :branches].each do |scope| - context "displaying #{scope}" do - let(:project) { create(:project) } - - before { visit namespace_project_pipelines_path(project.namespace, project, scope: scope) } - - it { expect(page).to have_content(pipeline.short_sha) } - end - end - - context 'anonymous access' do - before { visit namespace_project_pipelines_path(project.namespace, project) } + context 'when user is logged in' do + let(:user) { create(:user) } - it { expect(page).to have_http_status(:success) } + before do + login_as(user) + project.team << [user, :developer] end - context 'cancelable pipeline' do - let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') } - - before do - build.run - visit namespace_project_pipelines_path(project.namespace, project) + describe 'GET /:project/pipelines' do + let(:project) { create(:project) } + + let!(:pipeline) do + create( + :ci_empty_pipeline, + project: project, + ref: 'master', + status: 'running', + sha: project.commit.id, + ) end - it { expect(page).to have_link('Cancel') } - it { expect(page).to have_selector('.ci-running') } + [:all, :running, :branches].each do |scope| + context "when displaying #{scope}" do + before do + visit_project_pipelines(scope: scope) + end - context 'when canceling' do - before { click_link('Cancel') } - - it { expect(page).not_to have_link('Cancel') } - it { expect(page).to have_selector('.ci-canceled') } + it 'contains pipeline commit short SHA' do + expect(page).to have_content(pipeline.short_sha) + end + end end - end - context 'retryable pipelines' do - let!(:build) { create(:ci_build, pipeline: pipeline, stage: 'test', commands: 'test') } + context 'when pipeline is cancelable' do + let!(:build) do + create(:ci_build, pipeline: pipeline, + stage: 'test', + commands: 'test') + end - before do - build.drop - visit namespace_project_pipelines_path(project.namespace, project) - end + before do + build.run + visit_project_pipelines + end - it { expect(page).to have_link('Retry') } - it { expect(page).to have_selector('.ci-failed') } + it 'indicates that pipeline can be canceled' do + expect(page).to have_link('Cancel') + expect(page).to have_selector('.ci-running') + end - context 'when retrying' do - before { click_link('Retry') } + context 'when canceling' do + before { click_link('Cancel') } - it { expect(page).not_to have_link('Retry') } - it { expect(page).to have_selector('.ci-running') } + it 'indicated that pipelines was canceled' do + expect(page).not_to have_link('Cancel') + expect(page).to have_selector('.ci-canceled') + end + end end - end - context 'with manual actions' do - let!(:manual) do - create(:ci_build, :manual, pipeline: pipeline, - name: 'manual build', - stage: 'test', - commands: 'test') - end + context 'when pipeline is retryable' do + let!(:build) do + create(:ci_build, pipeline: pipeline, + stage: 'test', + commands: 'test') + end - before do - visit namespace_project_pipelines_path(project.namespace, project) - end + before do + build.drop + visit_project_pipelines + end - it 'has link to the manual action' do - find('.js-pipeline-dropdown-manual-actions').click + it 'indicates that pipeline can be retried' do + expect(page).to have_link('Retry') + expect(page).to have_selector('.ci-failed') + end - expect(page).to have_link('Manual build') - end + context 'when retrying' do + before { click_link('Retry') } - context 'when manual action was played' do - before do - find('.js-pipeline-dropdown-manual-actions').click - click_link('Manual build') + it 'shows running pipeline that is not retryable' do + expect(page).not_to have_link('Retry') + expect(page).to have_selector('.ci-running') + end end + end - it 'enqueues manual action job' do - expect(manual.reload).to be_pending + context 'when pipeline has configuration errors' do + let(:pipeline) do + create(:ci_pipeline, :invalid, project: project) end - end - end - context 'for generic statuses' do - context 'when running' do - let!(:running) { create(:generic_commit_status, status: 'running', pipeline: pipeline, stage: 'test') } + before { visit_project_pipelines } - before do - visit namespace_project_pipelines_path(project.namespace, project) + it 'contains badge that indicates errors' do + expect(page).to have_content 'yaml invalid' end - it 'is cancelable' do - expect(page).to have_link('Cancel') + it 'contains badge with tooltip which contains error' do + expect(pipeline).to have_yaml_errors + expect(page).to have_selector( + %Q{span[data-original-title="#{pipeline.yaml_errors}"]}) end + end - it 'has pipeline running' do - expect(page).to have_selector('.ci-running') + context 'with manual actions' do + let!(:manual) do + create(:ci_build, :manual, + pipeline: pipeline, + name: 'manual build', + stage: 'test', + commands: 'test') end - context 'when canceling' do - before { click_link('Cancel') } + before { visit_project_pipelines } - it { expect(page).not_to have_link('Cancel') } - it { expect(page).to have_selector('.ci-canceled') } + it 'has a dropdown with play button' do + expect(page).to have_selector('.dropdown-toggle.btn.btn-default .icon-play') end - end - context 'when failed' do - let!(:status) { create(:generic_commit_status, :pending, pipeline: pipeline, stage: 'test') } + it 'has link to the manual action' do + find('.js-pipeline-dropdown-manual-actions').click - before do - status.drop - visit namespace_project_pipelines_path(project.namespace, project) + expect(page).to have_link('Manual build') end - it 'is not retryable' do - expect(page).not_to have_link('Retry') - end + context 'when manual action was played' do + before do + find('.js-pipeline-dropdown-manual-actions').click + click_link('Manual build') + end - it 'has failed pipeline' do - expect(page).to have_selector('.ci-failed') + it 'enqueues manual action job' do + expect(manual.reload).to be_pending + end end end - end - - context 'downloadable pipelines' do - context 'with artifacts' do - let!(:with_artifacts) { create(:ci_build, :artifacts, :success, pipeline: pipeline, name: 'rspec tests', stage: 'test') } - before { visit namespace_project_pipelines_path(project.namespace, project) } + context 'for generic statuses' do + context 'when running' do + let!(:running) do + create(:generic_commit_status, + status: 'running', + pipeline: pipeline, + stage: 'test') + end + + before { visit_project_pipelines } + + it 'is cancelable' do + expect(page).to have_link('Cancel') + end + + it 'has pipeline running' do + expect(page).to have_selector('.ci-running') + end + + context 'when canceling' do + before { click_link('Cancel') } + + it 'indicates that pipeline was canceled' do + expect(page).not_to have_link('Cancel') + expect(page).to have_selector('.ci-canceled') + end + end + end - it { expect(page).to have_selector('.build-artifacts') } - it do - find('.js-pipeline-dropdown-download').click - expect(page).to have_link(with_artifacts.name) + context 'when failed' do + let!(:status) do + create(:generic_commit_status, :pending, + pipeline: pipeline, + stage: 'test') + end + + before do + status.drop + visit_project_pipelines + end + + it 'is not retryable' do + expect(page).not_to have_link('Retry') + end + + it 'has failed pipeline' do + expect(page).to have_selector('.ci-failed') + end end end - context 'with artifacts expired' do - let!(:with_artifacts_expired) { create(:ci_build, :artifacts_expired, :success, pipeline: pipeline, name: 'rspec', stage: 'test') } + context 'downloadable pipelines' do + context 'with artifacts' do + let!(:with_artifacts) do + create(:ci_build, :artifacts, :success, + pipeline: pipeline, + name: 'rspec tests', + stage: 'test') + end - before { visit namespace_project_pipelines_path(project.namespace, project) } + before { visit_project_pipelines } - it { expect(page).not_to have_selector('.build-artifacts') } - end + it 'has artifats' do + expect(page).to have_selector('.build-artifacts') + end - context 'without artifacts' do - let!(:without_artifacts) { create(:ci_build, :success, pipeline: pipeline, name: 'rspec', stage: 'test') } + it 'has artifacts download dropdown' do + find('.js-pipeline-dropdown-download').click - before { visit namespace_project_pipelines_path(project.namespace, project) } + expect(page).to have_link(with_artifacts.name) + end + end - it { expect(page).not_to have_selector('.build-artifacts') } - end - end + context 'with artifacts expired' do + let!(:with_artifacts_expired) do + create(:ci_build, :artifacts_expired, :success, + pipeline: pipeline, + name: 'rspec', + stage: 'test') + end - context 'mini pipleine graph' do - let!(:build) do - create(:ci_build, pipeline: pipeline, stage: 'build', name: 'build') - end + before { visit_project_pipelines } - before do - visit namespace_project_pipelines_path(project.namespace, project) - end + it { expect(page).not_to have_selector('.build-artifacts') } + end + + context 'without artifacts' do + let!(:without_artifacts) do + create(:ci_build, :success, + pipeline: pipeline, + name: 'rspec', + stage: 'test') + end - it 'should render a mini pipeline graph' do - endpoint = stage_namespace_project_pipeline_path(pipeline.project.namespace, pipeline.project, pipeline, stage: build.name) + before { visit_project_pipelines } - expect(page).to have_selector('.js-mini-pipeline-graph') - expect(page).to have_selector(".js-builds-dropdown-button[data-stage-endpoint='#{endpoint}']") + it { expect(page).not_to have_selector('.build-artifacts') } + end end - context 'when clicking a graph stage' do - it 'should open a dropdown' do - find('.js-builds-dropdown-button').trigger('click') + context 'mini pipeline graph' do + let!(:build) do + create(:ci_build, :pending, pipeline: pipeline, + stage: 'build', + name: 'build') + end - wait_for_ajax + before { visit_project_pipelines } - expect(page).to have_link build.name + it 'should render a mini pipeline graph' do + expect(page).to have_selector('.js-mini-pipeline-graph') + expect(page).to have_selector('.js-builds-dropdown-button') end - it 'should be possible to retry the failed build' do - find('.js-builds-dropdown-button').trigger('click') + context 'when clicking a stage badge' do + it 'should open a dropdown' do + find('.js-builds-dropdown-button').trigger('click') + + expect(page).to have_link build.name + end - wait_for_ajax + it 'should be possible to cancel pending build' do + find('.js-builds-dropdown-button').trigger('click') + find('a.js-ci-action-icon').trigger('click') - find('a.js-ci-action-icon').trigger('click') - expect(page).not_to have_content('Cancel running') + expect(page).to have_content('canceled') + expect(build.reload).to be_canceled + end end end end - end - describe 'POST /:project/pipelines' do - let(:project) { create(:project) } + describe 'POST /:project/pipelines' do + let(:project) { create(:project) } - before { visit new_namespace_project_pipeline_path(project.namespace, project) } + before do + visit new_namespace_project_pipeline_path(project.namespace, project) + end + + context 'for valid commit' do + before { fill_in('pipeline[ref]', with: 'master') } + + context 'with gitlab-ci.yml' do + before { stub_ci_pipeline_to_return_yaml_file } - context 'for valid commit' do - before { fill_in('pipeline[ref]', with: 'master') } + it 'creates a new pipeline' do + expect { click_on 'Create pipeline' } + .to change { Ci::Pipeline.count }.by(1) + end + end - context 'with gitlab-ci.yml' do - before { stub_ci_pipeline_to_return_yaml_file } + context 'without gitlab-ci.yml' do + before { click_on 'Create pipeline' } - it { expect{ click_on 'Create pipeline' }.to change{ Ci::Pipeline.count }.by(1) } + it { expect(page).to have_content('Missing .gitlab-ci.yml file') } + end end - context 'without gitlab-ci.yml' do - before { click_on 'Create pipeline' } + context 'for invalid commit' do + before do + fill_in('pipeline[ref]', with: 'invalid-reference') + click_on 'Create pipeline' + end - it { expect(page).to have_content('Missing .gitlab-ci.yml file') } + it { expect(page).to have_content('Reference not found') } end end - context 'for invalid commit' do + describe 'Create pipelines' do + let(:project) { create(:project) } + before do - fill_in('pipeline[ref]', with: 'invalid-reference') - click_on 'Create pipeline' + visit new_namespace_project_pipeline_path(project.namespace, project) + end + + describe 'new pipeline page' do + it 'has field to add a new pipeline' do + expect(page).to have_field('pipeline[ref]') + expect(page).to have_content('Create for') + end end - it { expect(page).to have_content('Reference not found') } + describe 'find pipelines' do + it 'shows filtered pipelines', js: true do + fill_in('pipeline[ref]', with: 'fix') + find('input#ref').native.send_keys(:keydown) + + within('.ui-autocomplete') do + expect(page).to have_selector('li', text: 'fix') + end + end + end end end - describe 'Create pipelines', feature: true do - let(:project) { create(:project) } - + context 'when user is not logged in' do before do - visit new_namespace_project_pipeline_path(project.namespace, project) + visit namespace_project_pipelines_path(project.namespace, project) end - describe 'new pipeline page' do - it 'has field to add a new pipeline' do - expect(page).to have_field('pipeline[ref]') - expect(page).to have_content('Create for') - end + context 'when project is public' do + let(:project) { create(:project, :public) } + + it { expect(page).to have_content 'No pipelines to show' } + it { expect(page).to have_http_status(:success) } end - describe 'find pipelines' do - it 'shows filtered pipelines', js: true do - fill_in('pipeline[ref]', with: 'fix') - find('input#ref').native.send_keys(:keydown) + context 'when project is private' do + let(:project) { create(:project, :private) } - within('.ui-autocomplete') do - expect(page).to have_selector('li', text: 'fix') - end - end + it { expect(page).to have_content 'You need to sign in' } end end + + def visit_project_pipelines(**query) + visit namespace_project_pipelines_path(project.namespace, project, query) + wait_for_vue_resource + end end diff --git a/spec/javascripts/vue_pagination/pagination_spec.js.es6 b/spec/javascripts/vue_pagination/pagination_spec.js.es6 new file mode 100644 index 00000000000..1a7f2bb5fb8 --- /dev/null +++ b/spec/javascripts/vue_pagination/pagination_spec.js.es6 @@ -0,0 +1,168 @@ +//= require vue +//= require lib/utils/common_utils +//= require vue_pagination/index +/* global fixture, gl */ + +describe('Pagination component', () => { + let component; + + const changeChanges = { + one: '', + two: '', + }; + + const change = (one, two) => { + changeChanges.one = one; + changeChanges.two = two; + }; + + it('should render and start at page 1', () => { + fixture.set('<div class="test-pagination-container"></div>'); + + component = new window.gl.VueGlPagination({ + el: document.querySelector('.test-pagination-container'), + propsData: { + pageInfo: { + totalPages: 10, + nextPage: 2, + previousPage: '', + }, + change, + }, + }); + + expect(component.$el.classList).toContain('gl-pagination'); + + component.changePage({ target: { innerText: '1' } }); + + expect(changeChanges.one).toEqual(1); + expect(changeChanges.two).toEqual('all'); + }); + + it('should go to the previous page', () => { + fixture.set('<div class="test-pagination-container"></div>'); + + component = new window.gl.VueGlPagination({ + el: document.querySelector('.test-pagination-container'), + propsData: { + pageInfo: { + totalPages: 10, + nextPage: 3, + previousPage: 1, + }, + change, + }, + }); + + component.changePage({ target: { innerText: 'Prev' } }); + + expect(changeChanges.one).toEqual(1); + expect(changeChanges.two).toEqual('all'); + }); + + it('should go to the next page', () => { + fixture.set('<div class="test-pagination-container"></div>'); + + component = new window.gl.VueGlPagination({ + el: document.querySelector('.test-pagination-container'), + propsData: { + pageInfo: { + totalPages: 10, + nextPage: 5, + previousPage: 3, + }, + change, + }, + }); + + component.changePage({ target: { innerText: 'Next' } }); + + expect(changeChanges.one).toEqual(5); + expect(changeChanges.two).toEqual('all'); + }); + + it('should go to the last page', () => { + fixture.set('<div class="test-pagination-container"></div>'); + + component = new window.gl.VueGlPagination({ + el: document.querySelector('.test-pagination-container'), + propsData: { + pageInfo: { + totalPages: 10, + nextPage: 5, + previousPage: 3, + }, + change, + }, + }); + + component.changePage({ target: { innerText: 'Last >>' } }); + + expect(changeChanges.one).toEqual(10); + expect(changeChanges.two).toEqual('all'); + }); + + it('should go to the first page', () => { + fixture.set('<div class="test-pagination-container"></div>'); + + component = new window.gl.VueGlPagination({ + el: document.querySelector('.test-pagination-container'), + propsData: { + pageInfo: { + totalPages: 10, + nextPage: 5, + previousPage: 3, + }, + change, + }, + }); + + component.changePage({ target: { innerText: '<< First' } }); + + expect(changeChanges.one).toEqual(1); + expect(changeChanges.two).toEqual('all'); + }); + + it('should do nothing', () => { + fixture.set('<div class="test-pagination-container"></div>'); + + component = new window.gl.VueGlPagination({ + el: document.querySelector('.test-pagination-container'), + propsData: { + pageInfo: { + totalPages: 10, + nextPage: 2, + previousPage: '', + }, + change, + }, + }); + + component.changePage({ target: { innerText: '...' } }); + + expect(changeChanges.one).toEqual(1); + expect(changeChanges.two).toEqual('all'); + }); +}); + +describe('paramHelper', () => { + it('can parse url parameters correctly', () => { + window.history.pushState({}, null, '?scope=all&p=2'); + + const scope = gl.utils.getParameterByName('scope'); + const p = gl.utils.getParameterByName('p'); + + expect(scope).toEqual('all'); + expect(p).toEqual('2'); + }); + + it('returns null if param not in url', () => { + window.history.pushState({}, null, '?p=2'); + + const scope = gl.utils.getParameterByName('scope'); + const p = gl.utils.getParameterByName('p'); + + expect(scope).toEqual(null); + expect(p).toEqual('2'); + }); +}); diff --git a/spec/lib/api/helpers/pagination_spec.rb b/spec/lib/api/helpers/pagination_spec.rb new file mode 100644 index 00000000000..267318faed4 --- /dev/null +++ b/spec/lib/api/helpers/pagination_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +describe API::Helpers::Pagination do + let(:resource) { Project.all } + + subject do + Class.new.include(described_class).new + end + + describe '#paginate' do + let(:value) { spy('return value') } + + before do + allow(value).to receive(:to_query).and_return(value) + + allow(subject).to receive(:header).and_return(value) + allow(subject).to receive(:params).and_return(value) + allow(subject).to receive(:request).and_return(value) + end + + describe 'required instance methods' do + let(:return_spy) { spy } + + it 'requires some instance methods' do + expect_message(:header) + expect_message(:params) + expect_message(:request) + + subject.paginate(resource) + end + end + + context 'when resource can be paginated' do + before do + create_list(:empty_project, 3) + end + + describe 'first page' do + before do + allow(subject).to receive(:params) + .and_return({ page: 1, per_page: 2 }) + end + + it 'returns appropriate amount of resources' do + expect(subject.paginate(resource).count).to eq 2 + end + + it 'adds appropriate headers' do + expect_header('X-Total', '3') + expect_header('X-Total-Pages', '2') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '1') + expect_header('X-Next-Page', '2') + expect_header('X-Prev-Page', '') + expect_header('Link', any_args) + + subject.paginate(resource) + end + end + + describe 'second page' do + before do + allow(subject).to receive(:params) + .and_return({ page: 2, per_page: 2 }) + end + + it 'returns appropriate amount of resources' do + expect(subject.paginate(resource).count).to eq 1 + end + + it 'adds appropriate headers' do + expect_header('X-Total', '3') + expect_header('X-Total-Pages', '2') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '2') + expect_header('X-Next-Page', '') + expect_header('X-Prev-Page', '1') + expect_header('Link', any_args) + + subject.paginate(resource) + end + end + end + + def expect_header(name, value) + expect(subject).to receive(:header).with(name, value) + end + + def expect_message(method) + expect(subject).to receive(method) + .at_least(:once).and_return(value) + end + end +end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index cebaa157ef3..d1aee27057a 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -888,6 +888,48 @@ describe Ci::Pipeline, models: true do end end + describe '#stuck?' do + before do + create(:ci_build, :pending, pipeline: pipeline) + end + + context 'when pipeline is stuck' do + it 'is stuck' do + expect(pipeline).to be_stuck + end + end + + context 'when pipeline is not stuck' do + before { create(:ci_runner, :shared, :online) } + + it 'is not stuck' do + expect(pipeline).not_to be_stuck + end + end + end + + describe '#has_yaml_errors?' do + context 'when pipeline has errors' do + let(:pipeline) do + create(:ci_pipeline, config: { rspec: nil }) + end + + it 'contains yaml errors' do + expect(pipeline).to have_yaml_errors + end + end + + context 'when pipeline does not have errors' do + let(:pipeline) do + create(:ci_pipeline, config: { rspec: { script: 'rake test' } }) + end + + it 'does not containyaml errors' do + expect(pipeline).not_to have_yaml_errors + end + end + end + describe 'notifications when pipeline success or failed' do let(:project) { create(:project) } diff --git a/spec/serializers/build_action_entity_spec.rb b/spec/serializers/build_action_entity_spec.rb new file mode 100644 index 00000000000..383704572b1 --- /dev/null +++ b/spec/serializers/build_action_entity_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe BuildActionEntity do + let(:build) { create(:ci_build, name: 'test_build') } + + let(:entity) do + described_class.new(build, request: double) + end + + describe '#as_json' do + subject { entity.as_json } + + it 'contains humanized build name' do + expect(subject[:name]).to eq 'Test build' + end + + it 'contains path to the action play' do + expect(subject[:path]).to include "builds/#{build.id}/play" + end + end +end diff --git a/spec/serializers/build_artifact_entity_spec.rb b/spec/serializers/build_artifact_entity_spec.rb new file mode 100644 index 00000000000..2fc60aa9de6 --- /dev/null +++ b/spec/serializers/build_artifact_entity_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe BuildArtifactEntity do + let(:build) { create(:ci_build, name: 'test:build') } + + let(:entity) do + described_class.new(build, request: double) + end + + describe '#as_json' do + subject { entity.as_json } + + it 'contains build name' do + expect(subject[:name]).to eq 'test:build' + end + + it 'contains path to the artifacts' do + expect(subject[:path]) + .to include "builds/#{build.id}/artifacts/download" + end + end +end diff --git a/spec/serializers/commit_entity_spec.rb b/spec/serializers/commit_entity_spec.rb index 15f11ac3df9..a8662e81d20 100644 --- a/spec/serializers/commit_entity_spec.rb +++ b/spec/serializers/commit_entity_spec.rb @@ -45,4 +45,8 @@ describe CommitEntity do subject end + + it 'exposes gravatar url that belongs to author' do + expect(subject.fetch(:author_gravatar_url)).to match /gravatar/ + end end diff --git a/spec/serializers/pipeline_entity_spec.rb b/spec/serializers/pipeline_entity_spec.rb new file mode 100644 index 00000000000..b19464c7117 --- /dev/null +++ b/spec/serializers/pipeline_entity_spec.rb @@ -0,0 +1,138 @@ +require 'spec_helper' + +describe PipelineEntity do + let(:user) { create(:user) } + let(:request) { double('request') } + + before do + allow(request).to receive(:user).and_return(user) + end + + let(:entity) do + described_class.represent(pipeline, request: request) + end + + describe '#as_json' do + subject { entity.as_json } + + context 'when pipeline is empty' do + let(:pipeline) { create(:ci_empty_pipeline) } + + it 'contains required fields' do + expect(subject).to include :id, :user, :path + expect(subject).to include :ref, :commit + expect(subject).to include :updated_at, :created_at + end + + it 'contains details' do + expect(subject).to include :details + expect(subject[:details]) + .to include :duration, :finished_at + expect(subject[:details]) + .to include :stages, :artifacts, :manual_actions + expect(subject[:details][:status]).to include :icon, :text, :label + end + + it 'contains flags' do + expect(subject).to include :flags + expect(subject[:flags]) + .to include :latest, :triggered, :stuck, + :yaml_errors, :retryable, :cancelable + end + end + + context 'when pipeline is retryable' do + let(:project) { create(:empty_project) } + + let(:pipeline) do + create(:ci_pipeline, status: :success, project: project) + end + + before do + create(:ci_build, :failed, pipeline: pipeline) + end + + context 'user has ability to retry pipeline' do + before { project.team << [user, :developer] } + + it 'retryable flag is true' do + expect(subject[:flags][:retryable]).to eq true + end + + it 'contains retry path' do + expect(subject[:retry_path]).to be_present + end + end + + context 'user does not have ability to retry pipeline' do + it 'retryable flag is false' do + expect(subject[:flags][:retryable]).to eq false + end + + it 'does not contain retry path' do + expect(subject).not_to have_key(:retry_path) + end + end + end + + context 'when pipeline is cancelable' do + let(:project) { create(:empty_project) } + + let(:pipeline) do + create(:ci_pipeline, status: :running, project: project) + end + + before do + create(:ci_build, :pending, pipeline: pipeline) + end + + context 'user has ability to cancel pipeline' do + before { project.team << [user, :developer] } + + it 'cancelable flag is true' do + expect(subject[:flags][:cancelable]).to eq true + end + + it 'contains cancel path' do + expect(subject[:cancel_path]).to be_present + end + end + + context 'user does not have ability to cancel pipeline' do + it 'cancelable flag is false' do + expect(subject[:flags][:cancelable]).to eq false + end + + it 'does not contain cancel path' do + expect(subject).not_to have_key(:cancel_path) + end + end + end + + context 'when pipeline has YAML errors' do + let(:pipeline) do + create(:ci_pipeline, config: { rspec: { invalid: :value } }) + end + + it 'contains flag that indicates there are errors' do + expect(subject[:flags][:yaml_errors]).to be true + end + + it 'contains information about error' do + expect(subject[:yaml_errors]).to be_present + end + end + + context 'when pipeline does not have YAML errors' do + let(:pipeline) { create(:ci_empty_pipeline) } + + it 'contains flag that indicates there are no errors' do + expect(subject[:flags][:yaml_errors]).to be false + end + + it 'does not contain field that normally holds an error' do + expect(subject).not_to have_key(:yaml_errors) + end + end + end +end diff --git a/spec/serializers/pipeline_serializer_spec.rb b/spec/serializers/pipeline_serializer_spec.rb new file mode 100644 index 00000000000..3a32cb394dd --- /dev/null +++ b/spec/serializers/pipeline_serializer_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +describe PipelineSerializer do + let(:user) { create(:user) } + + let(:serializer) do + described_class.new(user: user) + end + + let(:entity) do + serializer.represent(resource) + end + + subject { entity.as_json } + + describe '#represent' do + context 'when used without pagination' do + it 'created a not paginated serializer' do + expect(serializer).not_to be_paginated + end + + context 'when a single object is being serialized' do + let(:resource) { create(:ci_empty_pipeline) } + + it 'serializers the pipeline object' do + expect(subject[:id]).to eq resource.id + end + end + + context 'when multiple objects are being serialized' do + let(:resource) { create_list(:ci_pipeline, 2) } + + it 'serializers the array of pipelines' do + expect(subject).not_to be_empty + end + end + end + + context 'when used with pagination' do + let(:request) { spy('request') } + let(:response) { spy('response') } + let(:pagination) { {} } + + before do + allow(request) + .to receive(:query_parameters) + .and_return(pagination) + end + + let(:serializer) do + described_class.new(user: user) + .with_pagination(request, response) + end + + it 'created a paginated serializer' do + expect(serializer).to be_paginated + end + + context 'when resource does is not paginatable' do + context 'when a single pipeline object is being serialized' do + let(:resource) { create(:ci_empty_pipeline) } + let(:pagination) { { page: 1, per_page: 1 } } + + it 'raises error' do + expect { subject } + .to raise_error(PipelineSerializer::InvalidResourceError) + end + end + end + + context 'when resource is paginatable relation' do + let(:resource) { Ci::Pipeline.all } + let(:pagination) { { page: 1, per_page: 2 } } + + context 'when a single pipeline object is present in relation' do + before { create(:ci_empty_pipeline) } + + it 'serializes pipeline relation' do + expect(subject.first).to have_key :id + end + end + + context 'when a multiple pipeline objects are being serialized' do + before { create_list(:ci_empty_pipeline, 3) } + + it 'serializes appropriate number of objects' do + expect(subject.count).to be 2 + end + + it 'appends relevant headers' do + expect(response).to receive(:[]=).with('X-Total', '3') + expect(response).to receive(:[]=).with('X-Total-Pages', '2') + expect(response).to receive(:[]=).with('X-Per-Page', '2') + + subject + end + end + end + end + end +end diff --git a/spec/serializers/request_aware_entity_spec.rb b/spec/serializers/request_aware_entity_spec.rb new file mode 100644 index 00000000000..aa666b961dc --- /dev/null +++ b/spec/serializers/request_aware_entity_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe RequestAwareEntity do + subject do + Class.new.include(described_class).new + end + + it 'includes URL helpers' do + expect(subject).to respond_to(:namespace_project_path) + end + + it 'includes method for checking abilities' do + expect(subject).to respond_to(:can?) + end + + it 'fetches request from options' do + expect(subject).to receive(:options) + .and_return({ request: 'some value' }) + + expect(subject.request).to eq 'some value' + end +end diff --git a/spec/serializers/stage_entity_spec.rb b/spec/serializers/stage_entity_spec.rb new file mode 100644 index 00000000000..4ab40d08432 --- /dev/null +++ b/spec/serializers/stage_entity_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe StageEntity do + let(:pipeline) { create(:ci_pipeline) } + let(:request) { double('request') } + let(:user) { create(:user) } + + let(:entity) do + described_class.new(stage, request: request) + end + + let(:stage) do + build(:ci_stage, pipeline: pipeline, name: 'test') + end + + before do + allow(request).to receive(:user).and_return(user) + create(:ci_build, :success, pipeline: pipeline) + end + + describe '#as_json' do + subject { entity.as_json } + + it 'contains relevant fields' do + expect(subject).to include :name, :status, :path + end + + it 'contains detailed status' do + expect(subject[:status]).to include :text, :label, :group, :icon + expect(subject[:status][:label]).to eq 'passed' + end + + it 'contains valid name' do + expect(subject[:name]).to eq 'test' + end + + it 'contains path to the stage' do + expect(subject[:path]) + .to include "pipelines/#{pipeline.id}##{stage.name}" + end + + it 'contains path to the stage dropdown' do + expect(subject[:dropdown_path]) + .to include "pipelines/#{pipeline.id}/stage.json?stage=test" + end + + it 'contains stage title' do + expect(subject[:title]).to eq 'test: passed' + end + end +end diff --git a/spec/serializers/status_entity_spec.rb b/spec/serializers/status_entity_spec.rb new file mode 100644 index 00000000000..89428b4216e --- /dev/null +++ b/spec/serializers/status_entity_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +describe StatusEntity do + let(:entity) { described_class.new(status) } + + let(:status) do + Gitlab::Ci::Status::Success.new(double('object'), double('user')) + end + + before do + allow(status).to receive(:has_details?).and_return(true) + allow(status).to receive(:details_path).and_return('some/path') + end + + describe '#as_json' do + subject { entity.as_json } + + it 'contains status details' do + expect(subject).to include :text, :icon, :label, :group + expect(subject).to include :has_details, :details_path + end + end +end |