Commit 6b9733cf authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 8bdbb463 ccc73d1e
......@@ -159,6 +159,7 @@
/lib/gitlab/github_import/ @gitlab-org/maintainers/database
/app/finders/ @gitlab-org/maintainers/database
/ee/app/finders/ @gitlab-org/maintainers/database
/rubocop/rubocop-migrations.yml @gitlab-org/maintainers/database
[Engineering Productivity]
/.gitlab-ci.yml @gl-quality/eng-prod
......
......@@ -2,15 +2,13 @@
import { mapState, mapActions } from 'vuex';
import {
GlDrawer,
GlBadge,
GlIcon,
GlLink,
GlInfiniteScroll,
GlResizeObserverDirective,
GlTabs,
GlTab,
GlBadge,
GlLoadingIcon,
} from '@gitlab/ui';
import SkeletonLoader from './skeleton_loader.vue';
import Feature from './feature.vue';
import Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
......@@ -19,13 +17,11 @@ const trackingMixin = Tracking.mixin();
export default {
components: {
GlDrawer,
GlBadge,
GlIcon,
GlLink,
GlInfiniteScroll,
GlTabs,
GlTab,
SkeletonLoader,
Feature,
GlBadge,
GlLoadingIcon,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
......@@ -35,19 +31,11 @@ export default {
storageKey: {
type: String,
required: true,
},
versions: {
type: Array,
required: true,
},
gitlabDotCom: {
type: Boolean,
required: false,
default: false,
default: null,
},
},
computed: {
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']),
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight']),
},
mounted() {
this.openDrawer(this.storageKey);
......@@ -61,25 +49,14 @@ export default {
methods: {
...mapActions(['openDrawer', 'closeDrawer', 'fetchItems', 'setDrawerBodyHeight']),
bottomReached() {
const page = this.pageInfo.nextPage;
if (page) {
this.fetchItems({ page });
if (this.pageInfo.nextPage) {
this.fetchItems(this.pageInfo.nextPage);
}
},
handleResize() {
const height = getDrawerBodyHeight(this.$refs.drawer.$el);
this.setDrawerBodyHeight(height);
},
featuresForVersion(version) {
return this.features.filter(feature => {
return feature.release === parseFloat(version);
});
},
fetchVersion(version) {
if (this.featuresForVersion(version).length === 0) {
this.fetchItems({ version });
}
},
},
};
</script>
......@@ -96,39 +73,64 @@ export default {
<template #header>
<h4 class="page-title gl-my-2">{{ __("What's new at GitLab") }}</h4>
</template>
<template v-if="features.length">
<gl-infinite-scroll
v-if="gitlabDotCom"
:fetched-items="features.length"
:max-list-height="drawerBodyHeight"
class="gl-p-0"
@bottomReached="bottomReached"
>
<template #items>
<feature v-for="feature in features" :key="feature.title" :feature="feature" />
</template>
</gl-infinite-scroll>
<gl-tabs v-else :style="{ height: `${drawerBodyHeight}px` }" class="gl-p-0">
<gl-tab
v-for="(version, index) in versions"
:key="version"
@click="fetchVersion(version)"
<gl-infinite-scroll
v-if="features.length"
:fetched-items="features.length"
:max-list-height="drawerBodyHeight"
class="gl-p-0"
@bottomReached="bottomReached"
>
<template #items>
<div
v-for="feature in features"
:key="feature.title"
class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"
>
<template #title>
<span>{{ version }}</span>
<gl-badge v-if="index === 0">{{ __('Your Version') }}</gl-badge>
</template>
<gl-loading-icon v-if="fetching" size="lg" class="text-center" />
<template v-else>
<feature
v-for="feature in featuresForVersion(version)"
:key="feature.title"
:feature="feature"
<gl-link
:href="feature.url"
target="_blank"
class="whats-new-item-title-link"
data-track-event="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>
<h5 class="gl-font-lg">{{ feature.title }}</h5>
</gl-link>
<div v-if="feature.packages" class="gl-mb-3">
<gl-badge
v-for="package_name in feature.packages"
:key="package_name"
size="sm"
class="whats-new-item-badge gl-mr-2"
>
<gl-icon name="license" />{{ package_name }}
</gl-badge>
</div>
<gl-link
:href="feature.url"
target="_blank"
data-track-event="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>
<img
:alt="feature.title"
:src="feature.image_url"
class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image"
/>
</template>
</gl-tab>
</gl-tabs>
</template>
</gl-link>
<p class="gl-pt-3">{{ feature.body }}</p>
<gl-link
:href="feature.url"
target="_blank"
data-track-event="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>{{ __('Learn more') }}</gl-link
>
</div>
</template>
</gl-infinite-scroll>
<div v-else class="gl-mt-5">
<skeleton-loader />
<skeleton-loader />
......
<script>
import { GlBadge, GlIcon, GlLink } from '@gitlab/ui';
export default {
components: {
GlBadge,
GlIcon,
GlLink,
},
props: {
feature: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div class="gl-pb-7 gl-pt-5 gl-px-5 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100">
<gl-link
:href="feature.url"
target="_blank"
class="whats-new-item-title-link"
data-track-event="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>
<h5 class="gl-font-lg" data-test-id="feature-title">{{ feature.title }}</h5>
</gl-link>
<div v-if="feature.packages" class="gl-mb-3">
<gl-badge
v-for="packageName in feature.packages"
:key="packageName"
size="sm"
class="whats-new-item-badge gl-mr-2"
>
<gl-icon name="license" />{{ packageName }}
</gl-badge>
</div>
<gl-link
:href="feature.url"
target="_blank"
data-track-event="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>
<img
:alt="feature.title"
:src="feature.image_url"
class="img-thumbnail gl-px-8 gl-py-3 whats-new-item-image"
/>
</gl-link>
<p class="gl-pt-3">{{ feature.body }}</p>
<gl-link
:href="feature.url"
target="_blank"
data-track-event="click_whats_new_item"
:data-track-label="feature.title"
:data-track-property="feature.url"
>{{ __('Learn more') }}</gl-link
>
</div>
</template>
......@@ -10,6 +10,8 @@ export default el => {
if (whatsNewApp) {
store.dispatch('openDrawer');
} else {
const storageKey = getStorageKey(el);
whatsNewApp = new Vue({
el,
store,
......@@ -26,11 +28,7 @@ export default el => {
},
render(createElement) {
return createElement('app', {
props: {
storageKey: getStorageKey(el),
versions: JSON.parse(el.getAttribute('data-versions')),
gitlabDotCom: el.getAttribute('data-gitlab-dot-com'),
},
props: { storageKey },
});
},
});
......
......@@ -13,7 +13,7 @@ export default {
localStorage.setItem(storageKey, JSON.stringify(false));
}
},
fetchItems({ commit, state }, { page, version } = { page: null, version: null }) {
fetchItems({ commit, state }, page) {
if (state.fetching) {
return false;
}
......@@ -24,7 +24,6 @@ export default {
.get('/-/whats_new', {
params: {
page,
version,
},
})
.then(({ data, headers }) => {
......
......@@ -6,32 +6,6 @@
.gl-infinite-scroll-legend {
@include gl-display-none;
}
.gl-tabs {
@include gl-overflow-y-auto;
}
.gl-tabs-nav {
flex-wrap: nowrap;
overflow-x: scroll;
align-items: stretch;
.nav-item {
@include gl-flex-shrink-0;
a {
@include gl-h-full;
line-height: 1.5;
}
}
}
.gl-spinner-container {
@include gl-w-full;
@include gl-absolute;
top: 50%;
transform: translateY(-50%);
}
}
.with-performance-bar .whats-new-drawer {
......
# frozen_string_literal: true
class WhatsNewController < ApplicationController
include Gitlab::Utils::StrongMemoize
skip_before_action :authenticate_user!
before_action :check_feature_flag
before_action :check_valid_page_param, :set_pagination_headers, unless: -> { has_version_param? }
before_action :check_feature_flag, :check_valid_page_param, :set_pagination_headers
feature_category :navigation
def index
respond_to do |format|
format.js do
render json: highlight_items
render json: most_recent_items
end
end
end
......@@ -32,25 +29,15 @@ class WhatsNewController < ApplicationController
params[:page]&.to_i || 1
end
def highlights
strong_memoize(:highlights) do
if has_version_param?
ReleaseHighlight.for_version(version: params[:version])
else
ReleaseHighlight.paginated(page: current_page)
end
end
def most_recent
@most_recent ||= ReleaseHighlight.paginated(page: current_page)
end
def highlight_items
highlights.map {|item| Gitlab::WhatsNew::ItemPresenter.present(item) }
def most_recent_items
most_recent[:items].map {|item| Gitlab::WhatsNew::ItemPresenter.present(item) }
end
def set_pagination_headers
response.set_header('X-Next-Page', highlights.next_page)
end
def has_version_param?
params[:version].present?
response.set_header('X-Next-Page', most_recent[:next_page])
end
end
......@@ -6,14 +6,10 @@ module WhatsNewHelper
end
def whats_new_storage_key
most_recent_version = ReleaseHighlight.versions&.first
most_recent_version = ReleaseHighlight.most_recent_version
return unless most_recent_version
['display-whats-new-notification', most_recent_version].join('-')
end
def whats_new_versions
ReleaseHighlight.versions
end
end
......@@ -916,8 +916,20 @@ module Ci
end
def collect_coverage_reports!(coverage_report)
project_path, worktree_paths = if Feature.enabled?(:smart_cobertura_parser, project)
# If the flag is disabled, we intentionally pass nil
# for both project_path and worktree_paths to fallback
# to the non-smart behavior of the parser
[project.full_path, pipeline.all_worktree_paths]
end
each_report(Ci::JobArtifact::COVERAGE_REPORT_FILE_TYPES) do |file_type, blob|
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, coverage_report)
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(
blob,
coverage_report,
project_path: project_path,
worktree_paths: worktree_paths
)
end
coverage_report
......
......@@ -972,7 +972,7 @@ module Ci
def coverage_reports
Gitlab::Ci::Reports::CoverageReports.new.tap do |coverage_reports|
latest_report_builds(Ci::JobArtifact.coverage_reports).each do |build|
latest_report_builds(Ci::JobArtifact.coverage_reports).includes(:project).find_each do |build|
build.collect_coverage_reports!(coverage_reports)
end
end
......
......@@ -3,17 +3,6 @@
class ReleaseHighlight
CACHE_DURATION = 1.hour
FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml')
RELEASE_VERSIONS_IN_A_YEAR = 12
def self.for_version(version:)
index = self.versions.index(version)
return if index.nil?
page = index + 1
self.paginated(page: page)
end
def self.paginated(page: 1)
Rails.cache.fetch(cache_key(page), expires_in: CACHE_DURATION) do
......@@ -21,7 +10,10 @@ class ReleaseHighlight
next if items.nil?
QueryResult.new(items: items, next_page: next_page(current_page: page))
{
items: items,
next_page: next_page(current_page: page)
}
end
end
......@@ -61,25 +53,15 @@ class ReleaseHighlight
next_page if self.file_paths[next_index]
end
def self.most_recent_item_count
Gitlab::ProcessMemoryCache.cache_backend.fetch('release_highlight:recent_item_count', expires_in: CACHE_DURATION) do
self.paginated&.items&.count
def self.most_recent_version
Gitlab::ProcessMemoryCache.cache_backend.fetch('release_highlight:release_version', expires_in: CACHE_DURATION) do
self.paginated&.[](:items)&.first&.[]('release')
end
end
def self.versions
Gitlab::ProcessMemoryCache.cache_backend.fetch('release_highlight:versions', expires_in: CACHE_DURATION) do
versions = self.file_paths.first(RELEASE_VERSIONS_IN_A_YEAR).map do |path|
/\d*\_(\d*\_\d*)\.yml$/.match(path).captures[0].gsub(/0(?=\d)/, "").tr("_", ".")
end
versions.uniq
def self.most_recent_item_count
Gitlab::ProcessMemoryCache.cache_backend.fetch('release_highlight:recent_item_count', expires_in: CACHE_DURATION) do
self.paginated&.[](:items)&.count
end
end
QueryResult = Struct.new(:items, :next_page, keyword_init: true) do
include Enumerable
delegate :each, to: :items
end
end
......@@ -12,7 +12,7 @@ module Pages
# TODO: just remove this method after testing this in production
# https://gitlab.com/gitlab-org/gitlab/-/issues/282464
def try_obtain_lease
return yield unless Feature.enabled?(:pages_use_legacy_storage_lease, project)
return yield unless Feature.enabled?(:pages_use_legacy_storage_lease, project, default_enabled: true)
super
end
......
......@@ -102,7 +102,7 @@
= sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
- if ::Feature.enabled?(:whats_new_drawer, current_user)
#whats-new-app{ data: { storage_key: whats_new_storage_key, versions: whats_new_versions, gitlab_dot_com: Gitlab.dev_env_org_or_com? } }
#whats-new-app{ data: { storage_key: whats_new_storage_key } }
- if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: user_status_data }
---
title: Implement smart cobertura class path correction
merge_request: 48048
author:
type: changed
---
title: Add a job to the DAST template that shows an error in the console if the user is not licensed to use DAST.
merge_request: 47484
author:
type: changed
......@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/282464
milestone: '13.7'
type: development
group: group::release
default_enabled: false
default_enabled: true
---
name: smart_cobertura_parser
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48048
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/284822
milestone: '13.7'
type: development
group: group::testing
default_enabled: false
......@@ -165,7 +165,7 @@ export default {
<template>
<span class="epic-timeline-cell" data-qa-selector="epic_timeline_cell">
<current-day-indicator :preset-type="presetType" :timeframe-item="timeframeItem" />
<div class="epic-bar-wrapper">
<div class="gl-relative">
<a
v-if="hasStartDate"
:id="generateKey(epic)"
......
<script>
import { mapState } from 'vuex';
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
import { isInViewport } from '~/lib/utils/common_utils';
import { EXTEND_AS } from '../constants';
......@@ -12,7 +11,6 @@ import roadmapTimelineSection from './roadmap_timeline_section.vue';
export default {
components: {
GlSkeletonLoading,
epicsListSection,
milestonesListSection,
roadmapTimelineSection,
......@@ -92,7 +90,7 @@ export default {
<template>
<div
class="roadmap-shell js-roadmap-shell"
class="js-roadmap-shell gl-relative gl-h-full gl-w-full gl-overflow-x-auto"
data-qa-selector="roadmap_shell"
@scroll="handleScroll"
>
......@@ -109,13 +107,7 @@ export default {
:timeframe="timeframe"
:current-group-id="currentGroupId"
/>
<div v-if="!epics.length" class="skeleton-loader js-skeleton-loader">
<div v-for="n in 10" :key="n" class="mt-2">
<gl-skeleton-loading :lines="2" />
</div>
</div>
<epics-list-section
v-else
:preset-type="presetType"
:epics="epics"
:timeframe="timeframe"
......
......@@ -123,31 +123,6 @@ html.group-epics-roadmap-html {
}
}
.roadmap-shell {
position: relative;
height: 100%;
width: 100%;
overflow-x: auto;
.skeleton-loader {
position: absolute;
top: $header-item-height;
width: $details-cell-width;
height: 100%;
padding-top: $gl-padding-top;
padding-left: $gl-padding;
z-index: 4;
&::after {
height: 100%;
}
}
&.prevent-vertical-scroll {
overflow-y: hidden;
}
}
.roadmap-timeline-section .timeline-header-blank::after,
.epics-list-section .epic-details-cell::after,
.milestones-list-section .milestones-list-title::after,
......@@ -324,10 +299,6 @@ html.group-epics-roadmap-html {
}
}
.epic-bar-wrapper {
position: relative;
}
.epic-bar {
position: absolute;
top: 5px;
......
......@@ -67,9 +67,9 @@ RSpec.describe 'Epic show', :js do
it 'shows Roadmap timeline with child epics' do
page.within('.js-epic-tabs-content #roadmap') do
expect(page).to have_selector('.roadmap-container .roadmap-shell')
expect(page).to have_selector('.roadmap-container .js-roadmap-shell')
page.within('.roadmap-shell .epics-list-section') do
page.within('.js-roadmap-shell .epics-list-section') do
expect(page).not_to have_content(not_child.title)
expect(find('.epic-item-container:nth-child(1) .epics-list-item .epic-title')).to have_content('Child epic B')
expect(find('.epic-item-container:nth-child(2) .epics-list-item .epic-title')).to have_content('Child epic A')
......
......@@ -99,7 +99,7 @@ describe('EpicItemComponent', () => {
describe('timeframeString', () => {
it('returns timeframe string correctly when both start and end dates are defined', () => {
expect(wrapper.vm.timeframeString(mockEpic)).toBe('Jul 10, 2017 – Jun 2, 2018');
expect(wrapper.vm.timeframeString(mockEpic)).toBe('Nov 10, 2017 – Jun 2, 2018');
});
it('returns timeframe string correctly when no dates are defined', () => {
......@@ -113,7 +113,7 @@ describe('EpicItemComponent', () => {
const epic = { ...mockEpic, endDateUndefined: true };
wrapper = createComponent({ epic });
expect(wrapper.vm.timeframeString(epic)).toBe('Jul 10, 2017 – No end date');
expect(wrapper.vm.timeframeString(epic)).toBe('Nov 10, 2017 – No end date');
});
it('returns timeframe string correctly when only end date is defined', () => {
......
import Vue from 'vue';
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import roadmapShellComponent from 'ee/roadmap/components/roadmap_shell.vue';
import RoadmapShell from 'ee/roadmap/components/roadmap_shell.vue';
import { PRESET_TYPES } from 'ee/roadmap/constants';
import eventHub from 'ee/roadmap/event_hub';
import createStore from 'ee/roadmap/store';
......@@ -12,65 +13,71 @@ import {
mockGroupId,
mockMilestone,
} from 'ee_jest/roadmap/mock_data';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
const mockTimeframeMonths = getTimeframeForMonthsView(mockTimeframeInitialDate);
const createComponent = (
{
epics = [mockEpic],
milestones = [mockMilestone],
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
defaultInnerHeight = 0,
hasFiltersApplied = false,
},
el,
) => {
const Component = Vue.extend(roadmapShellComponent);
const store = createStore();
store.dispatch('setInitialData', {
defaultInnerHeight,
childrenFlags: { '1': { itemExpanded: false } },
});
describe('RoadmapShell', () => {
const localVue = createLocalVue();
localVue.use(Vuex);
return mountComponentWithStore(Component, {
el,
store,
props: {
presetType: PRESET_TYPES.MONTHS,
epics,
milestones,
timeframe,
currentGroupId,
hasFiltersApplied,
},
});
};
let store;
let wrapper;
describe('RoadmapShellComponent', () => {
let vm;
const storeFactory = ({ defaultInnerHeight = 0 }) => {
store = createStore();
store.dispatch('setInitialData', {
defaultInnerHeight,
childrenFlags: { '1': { itemExpanded: false } },
});
};
const createComponent = (
{
epics = [mockEpic],
milestones = [mockMilestone],
timeframe = mockTimeframeMonths,
currentGroupId = mockGroupId,
hasFiltersApplied = false,
},
el,
) => {
wrapper = mount(RoadmapShell, {
localVue,
store,
attachTo: el,
propsData: {
presetType: PRESET_TYPES.MONTHS,
epics,
milestones,
timeframe,
currentGroupId,
hasFiltersApplied,
},
});
};
beforeEach(done => {
vm = createComponent({});
vm.$nextTick(done);
beforeEach(() => {
storeFactory({});
createComponent({});
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
wrapper = null;
store = null;
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.timeframeStartOffset).toBe(0);
expect(wrapper.vm.timeframeStartOffset).toBe(0);
});
});
describe('methods', () => {
beforeEach(() => {
document.body.innerHTML +=
'<div class="roadmap-container"><div id="roadmap-shell"></div></div>';
'<div class="roadmap-container"><div data-testid="roadmap-shell"></div></div>';
createComponent({}, document.querySelector('[data-testid="roadmap-shell"]'));
});
afterEach(() => {
......@@ -78,38 +85,20 @@ describe('RoadmapShellComponent', () => {
});
describe('handleScroll', () => {
it('emits `epicsListScrolled` event via eventHub', done => {
const vmWithParentEl = createComponent({}, document.getElementById('roadmap-shell'));
it('emits `epicsListScrolled` event via eventHub', async () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
Vue.nextTick()
.then(() => {
vmWithParentEl.handleScroll();
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', expect.any(Object));
await wrapper.vm.$nextTick();
wrapper.vm.handleScroll();
vmWithParentEl.$destroy();
})
.then(done)
.catch(done.fail);
expect(eventHub.$emit).toHaveBeenCalledWith('epicsListScrolled', expect.any(Object));
});
});
});
describe('template', () => {
it('renders component container element with class `roadmap-shell`', () => {
expect(vm.$el.classList.contains('roadmap-shell')).toBe(true);
});
it('renders skeleton loader element when Epics list is empty', done => {
vm.epics = [];
vm.$nextTick()
.then(() => {
expect(vm.$el.querySelector('.js-skeleton-loader')).not.toBeNull();
})
.then(done)
.catch(done.fail);
it('renders component container element with class `js-roadmap-shell`', () => {
expect(wrapper.find('.js-roadmap-shell').exists()).toBe(true);
});
});
});
......@@ -104,8 +104,8 @@ export const mockEpic = {
groupId: 2,
groupName: 'Gitlab Org',
groupFullName: 'Gitlab Org',
startDate: new Date('2017-07-10'),
originalStartDate: new Date('2017-07-10'),
startDate: new Date('2017-11-10'),
originalStartDate: new Date('2017-11-10'),
endDate: new Date('2018-06-02'),
webUrl: '/groups/gitlab-org/-/epics/1',
descendantCounts: {
......
......@@ -47,8 +47,8 @@ RSpec.describe 'Jobs/DAST-Default-Branch-Deploy.gitlab-ci.yml' do
end
context 'when project has no license' do
it 'includes no DAST jobs' do
expect(build_names).to eq %w(placeholder)
it 'includes dast_unlicensed job' do
expect(build_names).to eq %w(placeholder dast_unlicensed)
end
end
......
......@@ -21,8 +21,72 @@ RSpec.describe 'DAST.gitlab-ci.yml' do
end
context 'when project has no license' do
it 'includes no jobs' do
expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
let(:cluster) { create(:cluster, :project, :provided_by_gcp, projects: [project]) }
context 'when k8 is active' do
before do
allow(cluster).to receive(:active?).and_return(true)
end
it 'includes dast_unlicensed job' do
expect(build_names).to match_array(%w[dast_unlicensed])
end
end
context 'when DAST_WEBSITE is active' do
before do
create(:ci_variable, project: project, key: 'DAST_WEBSITE', value: 'http://example.com')
end
it 'includes dast_unlicensed job' do
expect(build_names).to match_array(%w[dast_unlicensed])
end
end
context 'when DAST_API_SPECIFICATION is set' do
before do
create(:ci_variable, project: project, key: 'DAST_API_SPECIFICATION', value: 'http://example/open-api-v2.json')
end
it 'includes dast_unlicensed job' do
expect(build_names).to match_array(%w[dast_unlicensed])
end
end
context 'when DAST_DISABLED' do
before do
create(:ci_variable, project: project, key: 'DAST_DISABLED', value: '1')
end
it 'includes no job' do
expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
end
end
context 'when DAST_DISABLED_FOR_DEFAULT_BRANCH and CI_DEFAULT_BRANCH equals CI_COMMIT_REF_NAME' do
before do
create(:ci_variable, project: project, key: 'DAST_DISABLED_FOR_DEFAULT_BRANCH', value: '1')
create(:ci_variable, project: project, key: 'CI_DEFAULT_BRANCH', value: 'test_branch')
create(:ci_variable, project: project, key: 'CI_COMMIT_REF_NAME', value: 'test_branch')
end
it 'includes no job' do
expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
end
end
context 'when not on default branch and review disabled and both DAST_WEBSITE and DAST_API_SPECIFICATION are not set' do
before do
create(:ci_variable, project: project, key: 'CI_DEFAULT_BRANCH', value: 'main')
create(:ci_variable, project: project, key: 'CI_COMMIT_REF_NAME', value: 'test_branch')
create(:ci_variable, project: project, key: 'REVIEW_DISABLED', value: '1')
create(:ci_variable, project: project, key: 'DAST_WEBSITE', value: nil)
create(:ci_variable, project: project, key: 'DAST_API_SPECIFICATION', value: nil)
end
it 'includes no job' do
expect { pipeline }.to raise_error(Ci::CreatePipelineService::CreateError)
end
end
end
......@@ -56,7 +120,7 @@ RSpec.describe 'DAST.gitlab-ci.yml' do
create(:ci_variable, project: project, key: 'DAST_WEBSITE', value: 'http://example.com')
end
it 'includes job' do
it 'includes dast job' do
expect(build_names).to match_array(%w[dast])
end
end
......@@ -66,7 +130,7 @@ RSpec.describe 'DAST.gitlab-ci.yml' do
create(:ci_variable, project: project, key: 'DAST_API_SPECIFICATION', value: 'http://my.api/api-specification.yml')
end
it 'includes job' do
it 'includes dast job' do
expect(build_names).to match_array(%w[dast])
end
end
......@@ -104,7 +168,7 @@ RSpec.describe 'DAST.gitlab-ci.yml' do
project.repository.create_branch(pipeline_branch)
end
it 'includes job' do
it 'includes dast job' do
expect(build_names).to match_array(%w[dast])
end
end
......@@ -118,7 +182,7 @@ RSpec.describe 'DAST.gitlab-ci.yml' do
end
context 'when on default branch' do
it 'includes job' do
it 'includes dast job' do
expect(build_names).to match_array(%w[dast])
end
end
......
......@@ -211,7 +211,7 @@ module API
mount ::API::ProjectPackages
mount ::API::GroupPackages
mount ::API::PackageFiles
mount ::API::NugetPackages
mount ::API::NugetProjectPackages
mount ::API::PypiPackages
mount ::API::ComposerPackages
mount ::API::ConanProjectPackages
......
# frozen_string_literal: true
#
# NuGet Package Manager Client API
#
# These API endpoints are not consumed directly by users, so there is no documentation for the
# individual endpoints. They are called by the NuGet package manager client when users run commands
# like `nuget install` or `nuget push`. The usage of the GitLab NuGet registry is documented here:
# https://docs.gitlab.com/ee/user/packages/nuget_repository/
#
# Technical debt: https://gitlab.com/gitlab-org/gitlab/issues/35798
module API
module Concerns
module Packages
module NugetEndpoints
extend ActiveSupport::Concern
POSITIVE_INTEGER_REGEX = %r{\A[1-9]\d*\z}.freeze
NON_NEGATIVE_INTEGER_REGEX = %r{\A0|[1-9]\d*\z}.freeze
included do
helpers do
def find_packages
packages = package_finder.execute
not_found!('Packages') unless packages.exists?
packages
end
def find_package
package = package_finder(package_version: params[:package_version]).execute
.first
not_found!('Package') unless package
package
end
def package_finder(finder_params = {})
::Packages::Nuget::PackageFinder.new(
authorized_user_project,
**finder_params.merge(package_name: params[:package_name])
)
end
end
# https://docs.microsoft.com/en-us/nuget/api/service-index
desc 'The NuGet Service Index' do
detail 'This feature was introduced in GitLab 12.6'
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
get 'index', format: :json do
authorize_read_package!(authorized_user_project)
track_package_event('cli_metadata', :nuget, category: 'API::NugetPackages')
present ::Packages::Nuget::ServiceIndexPresenter.new(authorized_user_project),
with: ::API::Entities::Nuget::ServiceIndex
end
# https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource
params do
requires :package_name, type: String, desc: 'The NuGet package name', regexp: API::NO_SLASH_URL_PART_REGEX
end
namespace '/metadata/*package_name' do
before do
authorize_read_package!(authorized_user_project)
end
desc 'The NuGet Metadata Service - Package name level' do
detail 'This feature was introduced in GitLab 12.8'
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
get 'index', format: :json do
present ::Packages::Nuget::PackagesMetadataPresenter.new(find_packages),
with: ::API::Entities::Nuget::PackagesMetadata
end
desc 'The NuGet Metadata Service - Package name and version level' do
detail 'This feature was introduced in GitLab 12.8'
end
params do
requires :package_version, type: String, desc: 'The NuGet package version', regexp: API::NO_SLASH_URL_PART_REGEX
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
get '*package_version', format: :json do
present ::Packages::Nuget::PackageMetadataPresenter.new(find_package),
with: ::API::Entities::Nuget::PackageMetadata
end
end
# https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
params do
requires :q, type: String, desc: 'The search term'
optional :skip, type: Integer, desc: 'The number of results to skip', default: 0, regexp: NON_NEGATIVE_INTEGER_REGEX
optional :take, type: Integer, desc: 'The number of results to return', default: Kaminari.config.default_per_page, regexp: POSITIVE_INTEGER_REGEX
optional :prerelease, type: ::Grape::API::Boolean, desc: 'Include prerelease versions', default: true
end
namespace '/query' do
before do
authorize_read_package!(authorized_user_project)
end
desc 'The NuGet Search Service' do
detail 'This feature was introduced in GitLab 12.8'
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
get format: :json do
search_options = {
include_prerelease_versions: params[:prerelease],
per_page: params[:take],
padding: params[:skip]
}
search = ::Packages::Nuget::SearchService
.new(authorized_user_project, params[:q], search_options)
.execute
track_package_event('search_package', :nuget, category: 'API::NugetPackages')
present ::Packages::Nuget::SearchResultsPresenter.new(search),
with: ::API::Entities::Nuget::SearchResults
end
end
end
end
end
end
end
......@@ -6,15 +6,12 @@
# called by the NuGet package manager client when users run commands
# like `nuget install` or `nuget push`.
module API
class NugetPackages < ::API::Base
class NugetProjectPackages < ::API::Base
helpers ::API::Helpers::PackagesManagerClientsHelpers
helpers ::API::Helpers::Packages::BasicAuthHelpers
feature_category :package_registry
POSITIVE_INTEGER_REGEX = %r{\A[1-9]\d*\z}.freeze
NON_NEGATIVE_INTEGER_REGEX = %r{\A0|[1-9]\d*\z}.freeze
PACKAGE_FILENAME = 'package.nupkg'
default_format :json
......@@ -23,38 +20,12 @@ module API
render_api_error!(e.message, 400)
end
helpers do
def find_packages
packages = package_finder.execute
not_found!('Packages') unless packages.exists?
packages
end
def find_package
package = package_finder(package_version: params[:package_version]).execute
.first
not_found!('Package') unless package
package
end
def package_finder(finder_params = {})
::Packages::Nuget::PackageFinder.new(
authorized_user_project,
**finder_params.merge(package_name: params[:package_name])
)
end
end
before do
require_packages_enabled!
end
params do
requires :id, type: String, desc: 'The ID of a project', regexp: POSITIVE_INTEGER_REGEX
requires :id, type: String, desc: 'The ID of a project', regexp: ::API::Concerns::Packages::NugetEndpoints::POSITIVE_INTEGER_REGEX
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
......@@ -65,21 +36,7 @@ module API
end
namespace ':id/packages/nuget' do
# https://docs.microsoft.com/en-us/nuget/api/service-index
desc 'The NuGet Service Index' do
detail 'This feature was introduced in GitLab 12.6'
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
get 'index', format: :json do
authorize_read_package!(authorized_user_project)
track_package_event('cli_metadata', :nuget)
present ::Packages::Nuget::ServiceIndexPresenter.new(authorized_user_project),
with: ::API::Entities::Nuget::ServiceIndex
end
include ::API::Concerns::Packages::NugetEndpoints
# https://docs.microsoft.com/en-us/nuget/api/package-publish-resource
desc 'The NuGet Package Publish endpoint' do
......@@ -112,7 +69,7 @@ module API
file_params.merge(build: current_authenticated_job)
).execute
track_package_event('push_package', :nuget)
track_package_event('push_package', :nuget, category: 'API::NugetPackages')
::Packages::Nuget::ExtractionWorker.perform_async(package_file.id) # rubocop:disable CodeReuse/Worker
......@@ -133,41 +90,6 @@ module API
)
end
params do
requires :package_name, type: String, desc: 'The NuGet package name', regexp: API::NO_SLASH_URL_PART_REGEX
end
namespace '/metadata/*package_name' do
before do
authorize_read_package!(authorized_user_project)
end
# https://docs.microsoft.com/en-us/nuget/api/registration-base-url-resource
desc 'The NuGet Metadata Service - Package name level' do
detail 'This feature was introduced in GitLab 12.8'
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
get 'index', format: :json do
present ::Packages::Nuget::PackagesMetadataPresenter.new(find_packages),
with: ::API::Entities::Nuget::PackagesMetadata
end
desc 'The NuGet Metadata Service - Package name and version level' do
detail 'This feature was introduced in GitLab 12.8'
end
params do
requires :package_version, type: String, desc: 'The NuGet package version', regexp: API::NO_SLASH_URL_PART_REGEX
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
get '*package_version', format: :json do
present ::Packages::Nuget::PackageMetadataPresenter.new(find_package),
with: ::API::Entities::Nuget::PackageMetadata
end
end
# https://docs.microsoft.com/en-us/nuget/api/package-base-address-resource
params do
requires :package_name, type: String, desc: 'The NuGet package name', regexp: API::NO_SLASH_URL_PART_REGEX
......@@ -205,47 +127,12 @@ module API
not_found!('Package') unless package_file
track_package_event('pull_package', :nuget)
track_package_event('pull_package', :nuget, category: 'API::NugetPackages')
# nuget and dotnet don't support 302 Moved status codes, supports_direct_download has to be set to false
present_carrierwave_file!(package_file.file, supports_direct_download: false)
end
end
params do
requires :q, type: String, desc: 'The search term'
optional :skip, type: Integer, desc: 'The number of results to skip', default: 0, regexp: NON_NEGATIVE_INTEGER_REGEX
optional :take, type: Integer, desc: 'The number of results to return', default: Kaminari.config.default_per_page, regexp: POSITIVE_INTEGER_REGEX
optional :prerelease, type: Boolean, desc: 'Include prerelease versions', default: true
end
namespace '/query' do
before do
authorize_read_package!(authorized_user_project)
end
# https://docs.microsoft.com/en-us/nuget/api/search-query-service-resource
desc 'The NuGet Search Service' do
detail 'This feature was introduced in GitLab 12.8'
end
route_setting :authentication, deploy_token_allowed: true, job_token_allowed: :basic_auth, basic_auth_personal_access_token: true
get format: :json do
search_options = {
include_prerelease_versions: params[:prerelease],
per_page: params[:take],
padding: params[:skip]
}
search = Packages::Nuget::SearchService
.new(authorized_user_project, params[:q], search_options)
.execute
track_package_event('search_package', :nuget)
present ::Packages::Nuget::SearchResultsPresenter.new(search),
with: ::API::Entities::Nuget::SearchResults
end
end
end
end
end
......
......@@ -5,50 +5,113 @@ module Gitlab
module Parsers
module Coverage
class Cobertura
CoberturaParserError = Class.new(Gitlab::Ci::Parsers::ParserError)
InvalidXMLError = Class.new(Gitlab::Ci::Parsers::ParserError)
InvalidLineInformationError = Class.new(Gitlab::Ci::Parsers::ParserError)
def parse!(xml_data, coverage_report)
GO_SOURCE_PATTERN = '/usr/local/go/src'
MAX_SOURCES = 100
def parse!(xml_data, coverage_report, project_path: nil, worktree_paths: nil)
root = Hash.from_xml(xml_data)
parse_all(root, coverage_report)
context = {
project_path: project_path,
paths: worktree_paths&.to_set,
sources: []
}
parse_all(root, coverage_report, context)
rescue Nokogiri::XML::SyntaxError
raise CoberturaParserError, "XML parsing failed"
rescue
raise CoberturaParserError, "Cobertura parsing failed"
raise InvalidXMLError, "XML parsing failed"
end
private
def parse_all(root, coverage_report)
def parse_all(root, coverage_report, context)
return unless root.present?
root.each do |key, value|
parse_node(key, value, coverage_report)
parse_node(key, value, coverage_report, context)
end
end
def parse_node(key, value, coverage_report)
return if key == 'sources'
if key == 'class'
def parse_node(key, value, coverage_report, context)
if key == 'sources' && value['source'].present?
parse_sources(value['source'], context)
elsif key == 'package'
Array.wrap(value).each do |item|
parse_class(item, coverage_report)
parse_package(item, coverage_report, context)
end
elsif key == 'class'
# This means the cobertura XML does not have classes within package nodes.
# This is possible in some cases like in simple JS project structures
# running Jest.
Array.wrap(value).each do |item|
parse_class(item, coverage_report, context)
end
elsif value.is_a?(Hash)
parse_all(value, coverage_report)
parse_all(value, coverage_report, context)
elsif value.is_a?(Array)
value.each do |item|
parse_all(item, coverage_report)
parse_all(item, coverage_report, context)
end
end
end
def parse_class(file, coverage_report)
def parse_sources(sources, context)
return unless context[:project_path] && context[:paths]
sources = Array.wrap(sources)
# TODO: Go cobertura has a different format with how their packages
# are included in the filename. So we can't rely on the sources.
# We'll deal with this later.
return if sources.include?(GO_SOURCE_PATTERN)
sources.each do |source|
source = build_source_path(source, context)
context[:sources] << source if source.present?
end
end
def build_source_path(source, context)
# | raw source | extracted |
# |-----------------------------|------------|
# | /builds/foo/test/SampleLib/ | SampleLib/ |
# | /builds/foo/test/something | something |
# | /builds/foo/test/ | nil |
# | /builds/foo/test | nil |
source.split("#{context[:project_path]}/", 2)[1]
end
def parse_package(package, coverage_report, context)
classes = package.dig('classes', 'class')
return unless classes.present?
matched_filenames = Array.wrap(classes).map do |item|
parse_class(item, coverage_report, context)
end
# Remove these filenames from the paths to avoid conflict
# with other packages that may contain the same class filenames
remove_matched_filenames(matched_filenames, context)
end
def remove_matched_filenames(filenames, context)
return unless context[:paths]
filenames.each { |f| context[:paths].delete(f) }
end
def parse_class(file, coverage_report, context)
return unless file["filename"].present? && file["lines"].present?
parsed_lines = parse_lines(file["lines"])
filename = determine_filename(file["filename"], context)
coverage_report.add_file(filename, Hash[parsed_lines]) if filename
coverage_report.add_file(file["filename"], Hash[parsed_lines])
filename
end
def parse_lines(lines)
......@@ -58,6 +121,27 @@ module Gitlab
# Using `Integer()` here to raise exception on invalid values
[Integer(line["number"]), Integer(line["hits"])]
end
rescue
raise InvalidLineInformationError, "Line information had invalid values"
end
def determine_filename(filename, context)
return filename unless context[:sources].any?
full_filename = nil
context[:sources].each_with_index do |source, index|
break if index >= MAX_SOURCES
break if full_filename = check_source(source, filename, context)
end
full_filename
end
def check_source(source, filename, context)
full_path = File.join(source, filename)
return full_path if context[:paths].include?(full_path)
end
end
end
......
......@@ -49,3 +49,32 @@ dast:
- if: $CI_COMMIT_BRANCH &&
$GITLAB_FEATURES =~ /\bdast\b/ &&
$DAST_API_SPECIFICATION
dast_unlicensed:
stage: dast
allow_failure: true
variables:
GIT_STRATEGY: none
rules:
- if: $DAST_DISABLED
when: never
- if: $DAST_DISABLED_FOR_DEFAULT_BRANCH &&
$CI_DEFAULT_BRANCH == $CI_COMMIT_REF_NAME
when: never
- if: $CI_DEFAULT_BRANCH != $CI_COMMIT_REF_NAME &&
$REVIEW_DISABLED && $DAST_WEBSITE == null &&
$DAST_API_SPECIFICATION == null
when: never
- if: $CI_COMMIT_BRANCH &&
$CI_KUBERNETES_ACTIVE &&
$GITLAB_FEATURES !~ /\bdast\b/
- if: $CI_COMMIT_BRANCH &&
$GITLAB_FEATURES !~ /\bdast\b/ &&
$DAST_WEBSITE
- if: $CI_COMMIT_BRANCH &&
$GITLAB_FEATURES !~ /\bdast\b/ &&
$DAST_API_SPECIFICATION
script:
- |
echo "Error: Your GitLab project is not licensed for DAST."
- exit 1
......@@ -31747,9 +31747,6 @@ msgstr ""
msgid "Your U2F device was registered!"
msgstr ""
msgid "Your Version"
msgstr ""
msgid "Your WebAuthn device did not send a valid JSON response."
msgstr ""
......
......@@ -229,6 +229,16 @@ FactoryBot.define do
end
end
trait :coverage_with_paths_not_relative_to_project_root do
file_type { :cobertura }
file_format { :gzip }
after(:build) do |artifact, evaluator|
artifact.file = fixture_file_upload(
Rails.root.join('spec/fixtures/cobertura/coverage_with_paths_not_relative_to_project_root.xml.gz'), 'application/x-gzip')
end
end
trait :coverage_with_corrupted_data do
file_type { :cobertura }
file_format { :gzip }
......
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlDrawer, GlInfiniteScroll, GlTabs } from '@gitlab/ui';
import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import App from '~/whats_new/components/app.vue';
......@@ -16,18 +16,12 @@ const localVue = createLocalVue();
localVue.use(Vuex);
describe('App', () => {
const propsData = { storageKey: 'storage-key' };
let wrapper;
let store;
let actions;
let state;
let trackingSpy;
let gitlabDotCom = true;
const buildProps = () => ({
storageKey: 'storage-key',
versions: ['3.11', '3.10'],
gitlabDotCom,
});
const buildWrapper = () => {
actions = {
......@@ -51,7 +45,7 @@ describe('App', () => {
wrapper = mount(App, {
localVue,
store,
propsData: buildProps(),
propsData,
directives: {
GlResizeObserver: createMockDirective(),
},
......@@ -59,171 +53,112 @@ describe('App', () => {
};
const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll);
const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached');
const setup = async () => {
beforeEach(async () => {
document.body.dataset.page = 'test-page';
document.body.dataset.namespaceId = 'namespace-840';
trackingSpy = mockTracking('_category_', null, jest.spyOn);
buildWrapper();
wrapper.vm.$store.state.features = [
{ title: 'Whats New Drawer', url: 'www.url.com', release: 3.11 },
];
wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }];
wrapper.vm.$store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT;
await wrapper.vm.$nextTick();
};
});
afterEach(() => {
wrapper.destroy();
unmockTracking();
});
describe('gitlab.com', () => {
beforeEach(() => {
setup();
});
const getDrawer = () => wrapper.find(GlDrawer);
it('contains a drawer', () => {
expect(getDrawer().exists()).toBe(true);
});
it('dispatches openDrawer and tracking calls when mounted', () => {
expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
label: 'namespace_id',
value: 'namespace-840',
});
});
it('dispatches closeDrawer when clicking close', () => {
getDrawer().vm.$emit('close');
expect(actions.closeDrawer).toHaveBeenCalled();
});
it.each([true, false])('passes open property', async openState => {
wrapper.vm.$store.state.open = openState;
await wrapper.vm.$nextTick();
expect(getDrawer().props('open')).toBe(openState);
});
it('renders features when provided via ajax', () => {
expect(actions.fetchItems).toHaveBeenCalled();
expect(wrapper.find('[data-test-id="feature-title"]').text()).toBe('Whats New Drawer');
});
it('send an event when feature item is clicked', () => {
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
const getDrawer = () => wrapper.find(GlDrawer);
const link = wrapper.find('.whats-new-item-title-link');
triggerEvent(link.element);
it('contains a drawer', () => {
expect(getDrawer().exists()).toBe(true);
});
expect(trackingSpy.mock.calls[1]).toMatchObject([
'_category_',
'click_whats_new_item',
{
label: 'Whats New Drawer',
property: 'www.url.com',
},
]);
it('dispatches openDrawer and tracking calls when mounted', () => {
expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
label: 'namespace_id',
value: 'namespace-840',
});
});
it('renders infinite scroll', () => {
const scroll = findInfiniteScroll();
expect(scroll.props()).toMatchObject({
fetchedItems: wrapper.vm.$store.state.features.length,
maxListHeight: MOCK_DRAWER_BODY_HEIGHT,
});
});
it('dispatches closeDrawer when clicking close', () => {
getDrawer().vm.$emit('close');
expect(actions.closeDrawer).toHaveBeenCalled();
});
describe('bottomReached', () => {
const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached');
it.each([true, false])('passes open property', async openState => {
wrapper.vm.$store.state.open = openState;
beforeEach(() => {
actions.fetchItems.mockClear();
});
await wrapper.vm.$nextTick();
it('when nextPage exists it calls fetchItems', () => {
wrapper.vm.$store.state.pageInfo = { nextPage: 840 };
emitBottomReached();
expect(getDrawer().props('open')).toBe(openState);
});
expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { page: 840 });
});
it('renders features when provided via ajax', () => {
expect(actions.fetchItems).toHaveBeenCalled();
expect(wrapper.find('h5').text()).toBe('Whats New Drawer');
});
it('when nextPage does not exist it does not call fetchItems', () => {
wrapper.vm.$store.state.pageInfo = { nextPage: null };
emitBottomReached();
it('send an event when feature item is clicked', () => {
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
expect(actions.fetchItems).not.toHaveBeenCalled();
});
});
const link = wrapper.find('.whats-new-item-title-link');
triggerEvent(link.element);
it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => {
const { value } = getBinding(getDrawer().element, 'gl-resize-observer');
value();
expect(trackingSpy.mock.calls[1]).toMatchObject([
'_category_',
'click_whats_new_item',
{
label: 'Whats New Drawer',
property: 'www.url.com',
},
]);
});
expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element);
it('renders infinite scroll', () => {
const scroll = findInfiniteScroll();
expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith(
expect.any(Object),
MOCK_DRAWER_BODY_HEIGHT,
);
expect(scroll.props()).toMatchObject({
fetchedItems: wrapper.vm.$store.state.features.length,
maxListHeight: MOCK_DRAWER_BODY_HEIGHT,
});
});
describe('self managed', () => {
const findTabs = () => wrapper.find(GlTabs);
const clickSecondTab = async () => {
const secondTab = wrapper.findAll('.nav-link').at(1);
await secondTab.trigger('click');
await new Promise(resolve => requestAnimationFrame(resolve));
};
describe('bottomReached', () => {
beforeEach(() => {
gitlabDotCom = false;
setup();
actions.fetchItems.mockClear();
});
it('renders tabs with drawer body height and content', () => {
const scroll = findInfiniteScroll();
const tabs = findTabs();
it('when nextPage exists it calls fetchItems', () => {
wrapper.vm.$store.state.pageInfo = { nextPage: 840 };
emitBottomReached();
expect(scroll.exists()).toBe(false);
expect(tabs.attributes().style).toBe(`height: ${MOCK_DRAWER_BODY_HEIGHT}px;`);
expect(wrapper.find('h5').text()).toBe('Whats New Drawer');
expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), 840);
});
describe('fetchVersion', () => {
beforeEach(() => {
actions.fetchItems.mockClear();
});
it('when nextPage does not exist it does not call fetchItems', () => {
wrapper.vm.$store.state.pageInfo = { nextPage: null };
emitBottomReached();
it('when version isnt fetched, clicking a tab calls fetchItems', async () => {
const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion');
await clickSecondTab();
expect(actions.fetchItems).not.toHaveBeenCalled();
});
});
expect(fetchVersionSpy).toHaveBeenCalledWith('3.10');
expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { version: '3.10' });
});
it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => {
const { value } = getBinding(getDrawer().element, 'gl-resize-observer');
it('when version has been fetched, clicking a tab calls fetchItems', async () => {
wrapper.vm.$store.state.features.push({ title: 'GitLab Stories', release: 3.1 });
await wrapper.vm.$nextTick();
value();
const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion');
await clickSecondTab();
expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element);
expect(fetchVersionSpy).toHaveBeenCalledWith('3.10');
expect(actions.fetchItems).not.toHaveBeenCalled();
expect(wrapper.find('.tab-pane.active h5').text()).toBe('GitLab Stories');
});
});
expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith(
expect.any(Object),
MOCK_DRAWER_BODY_HEIGHT,
);
});
});
......@@ -41,23 +41,6 @@ describe('whats new actions', () => {
axiosMock.restore();
});
it('passes arguments', () => {
axiosMock.reset();
axiosMock
.onGet('/-/whats_new', { params: { page: 8, version: 40 } })
.replyOnce(200, [{ title: 'GitLab Stories' }]);
testAction(
actions.fetchItems,
{ page: 8, version: 40 },
{},
expect.arrayContaining([
{ type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] },
]),
);
});
it('if already fetching, does not fetch', () => {
testAction(actions.fetchItems, {}, { fetching: true }, []);
});
......
......@@ -10,7 +10,7 @@ RSpec.describe WhatsNewHelper do
let(:release_item) { double(:item) }
before do
allow(ReleaseHighlight).to receive(:versions).and_return([84.0])
allow(ReleaseHighlight).to receive(:most_recent_version).and_return(84.0)
end
it { is_expected.to eq('display-whats-new-notification-84.0') }
......@@ -18,7 +18,7 @@ RSpec.describe WhatsNewHelper do
context 'when most recent release highlights do NOT exist' do
before do
allow(ReleaseHighlight).to receive(:versions).and_return(nil)
allow(ReleaseHighlight).to receive(:most_recent_version).and_return(nil)
end
it { is_expected.to be_nil }
......@@ -44,14 +44,4 @@ RSpec.describe WhatsNewHelper do
end
end
end
describe '#whats_new_versions' do
let(:versions) { [84.0] }
it 'returns ReleaseHighlight.versions' do
expect(ReleaseHighlight).to receive(:versions).and_return(versions)
expect(helper.whats_new_versions).to eq(versions)
end
end
end
......@@ -4059,13 +4059,40 @@ RSpec.describe Ci::Build do
end
end
context 'when there is a Cobertura coverage report with class filename paths not relative to project root' do
before do
allow(build.project).to receive(:full_path).and_return('root/javademo')
allow(build.pipeline).to receive(:all_worktree_paths).and_return(['src/main/java/com/example/javademo/User.java'])
create(:ci_job_artifact, :coverage_with_paths_not_relative_to_project_root, job: build, project: build.project)
end
it 'parses blobs and add the results to the coverage report with corrected paths' do
expect { subject }.not_to raise_error
expect(coverage_report.files.keys).to match_array(['src/main/java/com/example/javademo/User.java'])
end
context 'and smart_cobertura_parser feature flag is disabled' do
before do
stub_feature_flags(smart_cobertura_parser: false)
end
it 'parses blobs and add the results to the coverage report with unmodified paths' do
expect { subject }.not_to raise_error
expect(coverage_report.files.keys).to match_array(['com/example/javademo/User.java'])
end
end
end
context 'when there is a corrupted Cobertura coverage report' do
before do
create(:ci_job_artifact, :coverage_with_corrupted_data, job: build, project: build.project)
end
it 'raises an error' do
expect { subject }.to raise_error(Gitlab::Ci::Parsers::Coverage::Cobertura::CoberturaParserError)
expect { subject }.to raise_error(Gitlab::Ci::Parsers::Coverage::Cobertura::InvalidLineInformationError)
end
end
end
......
......@@ -3418,6 +3418,16 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
])
end
it 'does not execute N+1 queries' do
single_build_pipeline = create(:ci_empty_pipeline, status: :created, project: project)
single_rspec = create(:ci_build, :success, name: 'rspec', pipeline: single_build_pipeline, project: project)
create(:ci_job_artifact, :cobertura, job: single_rspec, project: project)
control = ActiveRecord::QueryRecorder.new { single_build_pipeline.coverage_reports }
expect { subject }.not_to exceed_query_limit(control)
end
context 'when builds are retried' do
let!(:build_rspec) { create(:ci_build, :retried, :success, name: 'rspec', pipeline: pipeline, project: project) }
let!(:build_golang) { create(:ci_build, :retried, :success, name: 'golang', pipeline: pipeline, project: project) }
......
......@@ -3,44 +3,21 @@
require 'spec_helper'
RSpec.describe ReleaseHighlight do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
let(:cache_mock) { double(:cache_mock) }
before do
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
allow(cache_mock).to receive(:fetch).with('release_highlight:file_paths', expires_in: 1.hour).and_yield
end
after do
ReleaseHighlight.instance_variable_set(:@file_paths, nil)
end
describe '.for_version' do
subject { ReleaseHighlight.for_version(version: version) }
let(:version) { '1.1' }
context 'with version param that exists' do
it 'returns items from that version' do
expect(subject.items.first['title']).to eq("It's gonna be a bright")
end
end
context 'with version param that does NOT exist' do
let(:version) { '84.0' }
it 'returns nil' do
expect(subject).to be_nil
end
end
end
describe '.paginated' do
describe '#paginated' do
let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
let(:cache_mock) { double(:cache_mock) }
let(:dot_com) { false }
before do
allow(Gitlab).to receive(:com?).and_return(dot_com)
allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
expect(Rails).to receive(:cache).twice.and_return(cache_mock)
expect(cache_mock).to receive(:fetch).with('release_highlight:file_paths', expires_in: 1.hour).and_yield
end
after do
ReleaseHighlight.instance_variable_set(:@file_paths, nil)
end
context 'with page param' do
......@@ -113,51 +90,46 @@ RSpec.describe ReleaseHighlight do
end
end
describe '.most_recent_item_count' do
subject { ReleaseHighlight.most_recent_item_count }
describe '.most_recent_version' do
subject { ReleaseHighlight.most_recent_version }
context 'when recent release items exist' do
it 'returns the count from the most recent file' do
allow(ReleaseHighlight).to receive(:paginated).and_return(double(:paginated, items: [double(:item)]))
context 'when version exist' do
let(:release_item) { double(:item) }
expect(subject).to eq(1)
before do
allow(ReleaseHighlight).to receive(:paginated).and_return({ items: [release_item] })
allow(release_item).to receive(:[]).with('release').and_return(84.0)
end
it { is_expected.to eq(84.0) }
end
context 'when recent release items do NOT exist' do
it 'returns nil' do
context 'when most recent release highlights do NOT exist' do
before do
allow(ReleaseHighlight).to receive(:paginated).and_return(nil)
expect(subject).to be_nil
end
it { is_expected.to be_nil }
end
end
describe '.versions' do
it 'returns versions from the file paths' do
expect(ReleaseHighlight.versions).to eq(['1.5', '1.2', '1.1'])
end
describe '#most_recent_item_count' do
subject { ReleaseHighlight.most_recent_item_count }
context 'when there are more than 12 versions' do
let(:file_paths) do
i = 0
Array.new(20) { "20201225_01_#{i += 1}.yml" }
end
context 'when recent release items exist' do
it 'returns the count from the most recent file' do
allow(ReleaseHighlight).to receive(:paginated).and_return({ items: [double(:item)] })
it 'limits to 12 versions' do
allow(ReleaseHighlight).to receive(:file_paths).and_return(file_paths)
expect(ReleaseHighlight.versions.count).to eq(12)
expect(subject).to eq(1)
end
end
end
describe 'QueryResult' do
subject { ReleaseHighlight::QueryResult.new(items: items, next_page: 2) }
let(:items) { [:item] }
context 'when recent release items do NOT exist' do
it 'returns nil' do
allow(ReleaseHighlight).to receive(:paginated).and_return(nil)
it 'responds to map' do
expect(subject.map(&:to_s)).to eq(items.map(&:to_s))
expect(subject).to be_nil
end
end
end
end
......@@ -4,22 +4,22 @@ require 'spec_helper'
RSpec.describe WhatsNewController do
describe 'whats_new_path' do
let(:item) { double(:item) }
let(:highlights) { double(:highlight, items: [item], map: [item].map, next_page: 2) }
context 'with whats_new_drawer feature enabled' do
before do
stub_feature_flags(whats_new_drawer: true)
end
context 'with no page param' do
let(:most_recent) { { items: [item], next_page: 2 } }
let(:item) { double(:item) }
it 'responds with paginated data and headers' do
allow(ReleaseHighlight).to receive(:paginated).with(page: 1).and_return(highlights)
allow(ReleaseHighlight).to receive(:paginated).with(page: 1).and_return(most_recent)
allow(Gitlab::WhatsNew::ItemPresenter).to receive(:present).with(item).and_return(item)
get whats_new_path, xhr: true
expect(response.body).to eq(highlights.items.to_json)
expect(response.body).to eq(most_recent[:items].to_json)
expect(response.headers['X-Next-Page']).to eq(2)
end
end
......@@ -37,18 +37,6 @@ RSpec.describe WhatsNewController do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with version param' do
it 'returns items without pagination headers' do
allow(ReleaseHighlight).to receive(:for_version).with(version: '42').and_return(highlights)
allow(Gitlab::WhatsNew::ItemPresenter).to receive(:present).with(item).and_return(item)
get whats_new_path(version: 42), xhr: true
expect(response.body).to eq(highlights.items.to_json)
expect(response.headers['X-Next-Page']).to be_nil
end
end
end
context 'with whats_new_drawer feature disabled' do
......
......@@ -7,7 +7,8 @@ RSpec.describe ::Ci::Pipelines::CreateArtifactService do
subject { described_class.new.execute(pipeline) }
context 'when pipeline has coverage reports' do
let(:pipeline) { create(:ci_pipeline, :with_coverage_reports) }
let(:project) { create(:project, :repository) }
let(:pipeline) { create(:ci_pipeline, :with_coverage_reports, project: project) }
context 'when pipeline is finished' do
it 'creates a pipeline artifact' do
......
......@@ -26,7 +26,7 @@ RSpec.shared_examples 'process nuget service index request' do |user_type, statu
it_behaves_like 'returning response status', status
it_behaves_like 'a package tracking event', described_class.name, 'cli_metadata'
it_behaves_like 'a package tracking event', 'API::NugetPackages', 'cli_metadata'
it 'returns a valid json response' do
subject
......@@ -169,7 +169,7 @@ RSpec.shared_examples 'process nuget upload' do |user_type, status, add_member =
context 'with correct params' do
it_behaves_like 'package workhorse uploads'
it_behaves_like 'creates nuget package files'
it_behaves_like 'a package tracking event', described_class.name, 'push_package'
it_behaves_like 'a package tracking event', 'API::NugetPackages', 'push_package'
end
end
......@@ -286,7 +286,7 @@ RSpec.shared_examples 'process nuget download content request' do |user_type, st
it_behaves_like 'returning response status', status
it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
it_behaves_like 'a package tracking event', 'API::NugetPackages', 'pull_package'
it 'returns a valid package archive' do
subject
......@@ -336,7 +336,7 @@ RSpec.shared_examples 'process nuget search request' do |user_type, status, add_
it_behaves_like 'returns a valid json search response', status, 4, [1, 5, 5, 1]
it_behaves_like 'a package tracking event', described_class.name, 'search_package'
it_behaves_like 'a package tracking event', 'API::NugetPackages', 'search_package'
context 'with skip set to 2' do
let(:skip) { 2 }
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment