Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-07-05 12:09:46 +00:00
parent 402c915cb5
commit f34077e881
86 changed files with 1667 additions and 2917 deletions

View File

@ -2,6 +2,23 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 15.1.2 (2022-07-05)
### Fixed (3 changes)
- [Resolve "White screen of death on creating new project"](gitlab-org/gitlab@b737280d402aa88f723ada9885ccca22fa4457b5) ([merge request](gitlab-org/gitlab!91668))
- [Fix agent token modal](gitlab-org/gitlab@6fdffc4a534f67953a1555a0e4e35e4bd2bcb960) ([merge request](gitlab-org/gitlab!91668))
- [Resolve "Gitlab doesn't detect the deployment pods after K8s cluster upgrade to v1.22"](gitlab-org/gitlab@5eb84d7d96189f7119aa325e83a3723942cc14ba) ([merge request](gitlab-org/gitlab!91668))
### Changed (2 changes)
- [Update gitaly_cgroups metric name in docs](gitlab-org/gitlab@1af956596f052446f7ee2d42635b891670ddccd4) ([merge request](gitlab-org/gitlab!91668))
- [Refactor add populate commit permission migration](gitlab-org/gitlab@bc80cc41c2b90b8e459055c5ec1885941798f3c2) ([merge request](gitlab-org/gitlab!91668)) **GitLab Enterprise Edition**
### Removed (1 change)
- [Geo Sites Form - Remove Beta Badge](gitlab-org/gitlab@2feffa8e272aa8d9e608ad3e510a93fda93b7fcb) ([merge request](gitlab-org/gitlab!91668)) **GitLab Enterprise Edition**
## 15.1.1 (2022-06-30)
### Security (16 changes)

View File

@ -1,15 +1,15 @@
PATH
remote: vendor/gems/error_tracking_open_api
specs:
error_tracking_open_api (1.0.0)
typhoeus (~> 1.0, >= 1.0.1)
PATH
remote: vendor/gems/devise-pbkdf2-encryptable
specs:
devise-pbkdf2-encryptable (0.0.0)
devise (~> 4.0)
PATH
remote: vendor/gems/error_tracking_open_api
specs:
error_tracking_open_api (1.0.0)
typhoeus (~> 1.0, >= 1.0.1)
PATH
remote: vendor/gems/ipynbdiff
specs:

View File

@ -1,6 +1,6 @@
<script>
import { GlCollapse, GlButton, GlPopover, GlSkeletonLoader } from '@gitlab/ui';
import { getCookie, setCookie, parseBoolean, isLoggedIn } from '~/lib/utils/common_utils';
import { GlAccordion, GlAccordionItem, GlSkeletonLoader } from '@gitlab/ui';
import { isLoggedIn } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import Participants from '~/sidebar/components/participants/participants.vue';
@ -17,9 +17,8 @@ export default {
DesignDiscussion,
DesignNoteSignedOut,
Participants,
GlCollapse,
GlButton,
GlPopover,
GlAccordion,
GlAccordionItem,
GlSkeletonLoader,
DesignTodoButton,
},
@ -58,7 +57,7 @@ export default {
},
data() {
return {
isResolvedCommentsPopoverHidden: parseBoolean(getCookie(this.$options.cookieKey)),
isResolvedDiscussionsExpanded: this.resolvedDiscussionsExpanded,
discussionWithOpenForm: '',
isLoggedIn: isLoggedIn(),
};
@ -79,18 +78,22 @@ export default {
resolvedDiscussions() {
return this.discussions.filter((discussion) => discussion.resolved);
},
hasResolvedDiscussions() {
return this.resolvedDiscussions.length > 0;
},
resolvedDiscussionsTitle() {
return `${this.$options.i18n.resolveCommentsToggleText} (${this.resolvedDiscussions.length})`;
},
unresolvedDiscussions() {
return this.discussions.filter((discussion) => !discussion.resolved);
},
resolvedCommentsToggleIcon() {
return this.resolvedDiscussionsExpanded ? 'chevron-down' : 'chevron-right';
},
},
watch: {
isResolvedCommentsPopoverHidden(newVal) {
if (!newVal) {
this.$refs.resolvedComments.scrollIntoView();
}
resolvedDiscussionsExpanded(resolvedDiscussionsExpanded) {
this.isResolvedDiscussionsExpanded = resolvedDiscussionsExpanded;
},
isResolvedDiscussionsExpanded() {
this.$emit('toggleResolvedComments');
},
},
mounted() {
@ -100,8 +103,6 @@ export default {
},
methods: {
handleSidebarClick() {
this.isResolvedCommentsPopoverHidden = true;
setCookie(this.$options.cookieKey, 'true', { expires: 365 * 10 });
this.updateActiveDiscussion();
},
updateActiveDiscussion(id) {
@ -121,8 +122,9 @@ export default {
this.discussionWithOpenForm = id;
},
},
resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
cookieKey: 'hide_design_resolved_comments_popover',
i18n: {
resolveCommentsToggleText: s__('DesignManagement|Resolved Comments'),
},
};
</script>
@ -181,40 +183,12 @@ export default {
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
@open-form="updateDiscussionWithOpenForm"
/>
<template v-if="resolvedDiscussions.length > 0">
<gl-button
id="resolved-comments"
ref="resolvedComments"
data-testid="resolved-comments"
:icon="resolvedCommentsToggleIcon"
variant="link"
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
@click="$emit('toggleResolvedComments')"
>{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }})
</gl-button>
<gl-popover
v-if="!isResolvedCommentsPopoverHidden"
:show="!isResolvedCommentsPopoverHidden"
target="resolved-comments"
container="popovercontainer"
placement="top"
:title="s__('DesignManagement|Resolved Comments')"
<gl-accordion v-if="hasResolvedDiscussions" :header-level="3" class="gl-mb-5">
<gl-accordion-item
v-model="isResolvedDiscussionsExpanded"
:title="resolvedDiscussionsTitle"
header-class="gl-mb-5!"
>
<p>
{{
s__(
'DesignManagement|Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below',
)
}}
</p>
<a
href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
rel="noopener noreferrer"
target="_blank"
>{{ s__('DesignManagement|Learn more about resolving comments') }}</a
>
</gl-popover>
<gl-collapse :visible="resolvedDiscussionsExpanded" class="gl-mt-3">
<design-discussion
v-for="discussion in resolvedDiscussions"
:key="discussion.id"
@ -232,8 +206,8 @@ export default {
@open-form="updateDiscussionWithOpenForm"
@click.native.stop="updateActiveDiscussion(discussion.notes[0].id)"
/>
</gl-collapse>
</template>
</gl-accordion-item>
</gl-accordion>
<slot name="reply-form"></slot>
</template>
</div>

View File

@ -0,0 +1,56 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants';
export default {
components: { GlButton, GlIcon },
props: {
line: {
type: Number,
required: true,
},
codeQuality: {
type: Array,
required: true,
},
},
methods: {
severityClass(severity) {
return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown;
},
severityIcon(severity) {
return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown;
},
},
};
</script>
<template>
<div data-testid="diff-codequality" class="gl-relative">
<ul
class="gl-list-style-none gl-mb-0 gl-p-0 codequality-findings-list gl-border-top-1 gl-border-bottom-1 gl-bg-gray-10"
>
<li
v-for="finding in codeQuality"
:key="finding.description"
class="gl-pt-1 gl-pb-1 gl-pl-3 gl-border-solid gl-border-bottom-0 gl-border-right-0 gl-border-1 gl-border-gray-100"
>
<gl-icon
:size="12"
:name="severityIcon(finding.severity)"
:class="severityClass(finding.severity)"
class="codequality-severity-icon"
/>
{{ finding.description }}
</li>
</ul>
<gl-button
data-testid="diff-codequality-close"
category="tertiary"
size="small"
icon="close"
class="gl-absolute gl-right-2 gl-top-2"
@click="$emit('hideCodeQualityFindings', line)"
/>
</div>
</template>

View File

@ -274,6 +274,9 @@ export default {
v-if="$options.showCodequalityLeft(props)"
:codequality="props.line.left.codequality"
:file-path="props.filePath"
@showCodeQualityFindings="
listeners.toggleCodeQualityFindings(props.line.left.codequality[0].line)
"
/>
</div>
<div
@ -395,6 +398,9 @@ export default {
:codequality="props.line.right.codequality"
:file-path="props.filePath"
data-testid="codeQualityIcon"
@showCodeQualityFindings="
listeners.toggleCodeQualityFindings(props.line.right.codequality[0].line)
"
/>
</div>
<div

View File

@ -2,12 +2,14 @@
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import { mapGetters, mapState, mapActions } from 'vuex';
import { IdState } from 'vendor/vue-virtual-scroller';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import { getCommentedLines } from '~/notes/components/multiline_comment_utils';
import { hide } from '~/tooltips';
import { pickDirection } from '../utils/diff_line';
import DiffCommentCell from './diff_comment_cell.vue';
import DiffCodeQuality from './diff_code_quality.vue';
import DiffExpansionCell from './diff_expansion_cell.vue';
import DiffRow from './diff_row.vue';
import { isHighlighted } from './diff_row_utils';
@ -17,12 +19,17 @@ export default {
DiffExpansionCell,
DiffRow,
DiffCommentCell,
DiffCodeQuality,
DraftNote,
},
directives: {
SafeHtml,
},
mixins: [draftCommentsMixin, IdState({ idProp: (vm) => vm.diffFile.file_hash })],
mixins: [
draftCommentsMixin,
IdState({ idProp: (vm) => vm.diffFile.file_hash }),
glFeatureFlagsMixin(),
],
props: {
diffFile: {
type: Object,
@ -43,6 +50,11 @@ export default {
default: false,
},
},
data() {
return {
codeQualityExpandedLines: [],
};
},
idState() {
return {
dragStart: null,
@ -84,6 +96,23 @@ export default {
}
this.idState.dragStart = line;
},
parseCodeQuality(line) {
return (line.left ?? line.right)?.codequality;
},
hideCodeQualityFindings(line) {
const index = this.codeQualityExpandedLines.indexOf(line);
if (index > -1) {
this.codeQualityExpandedLines.splice(index, 1);
}
},
toggleCodeQualityFindings(line) {
if (!this.codeQualityExpandedLines.includes(line)) {
this.codeQualityExpandedLines.push(line);
} else {
this.hideCodeQualityFindings(line);
}
},
onDragOver(line) {
if (line.chunk !== this.idState.dragStart.chunk) return;
@ -125,15 +154,16 @@ export default {
},
handleParallelLineMouseDown(e) {
const line = e.target.closest('.diff-td');
const table = line.closest('.diff-table');
if (line) {
const table = line.closest('.diff-table');
table.classList.remove('left-side-selected', 'right-side-selected');
const [lineClass] = ['left-side', 'right-side'].filter((name) =>
line.classList.contains(name),
);
table.classList.remove('left-side-selected', 'right-side-selected');
const [lineClass] = ['left-side', 'right-side'].filter((name) =>
line.classList.contains(name),
);
if (lineClass) {
table.classList.add(`${lineClass}-selected`);
if (lineClass) {
table.classList.add(`${lineClass}-selected`);
}
}
},
getCountBetweenIndex(index) {
@ -148,6 +178,9 @@ export default {
Number(this.diffLines[index - 1].left.new_line)
);
},
getCodeQualityLine(line) {
return this.parseCodeQuality(line)?.[0]?.line;
},
},
userColorScheme: window.gon.user_color_scheme,
};
@ -190,6 +223,7 @@ export default {
:coverage-loaded="coverageLoaded"
@showCommentForm="(code) => singleLineComment(code, line)"
@setHighlightedRow="setHighlightedRow"
@toggleCodeQualityFindings="toggleCodeQualityFindings"
@toggleLineDiscussions="
({ lineCode, expanded }) =>
toggleLineDiscussions({ lineCode, fileHash: diffFile.file_hash, expanded })
@ -198,6 +232,17 @@ export default {
@startdragging="onStartDragging"
@stopdragging="onStopDragging"
/>
<diff-code-quality
v-if="
glFeatures.refactorCodeQualityInlineFindings &&
codeQualityExpandedLines.includes(getCodeQualityLine(line))
"
:key="line.line_code"
:line="getCodeQualityLine(line)"
:code-quality="parseCodeQuality(line)"
@hideCodeQualityFindings="hideCodeQualityFindings"
/>
<div
v-if="line.renderCommentRow"
:key="`dcr-${line.line_code || index}`"

View File

@ -1,8 +1,15 @@
<script>
import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import {
GlSearchBoxByType,
GlOutsideDirective as Outside,
GlIcon,
GlToken,
GlResizeObserverDirective,
} from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
import { truncate } from '~/lib/utils/text_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { s__, sprintf } from '~/locale';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
@ -12,6 +19,8 @@ import {
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
SCOPE_TOKEN_MAX_LENGTH,
INPUT_FIELD_PADDING,
} from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
@ -34,14 +43,17 @@ export default {
'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
),
searchResultsLoading: s__('GlobalSearch|Search results are loading'),
searchResultsScope: s__('GlobalSearch|in %{scope}'),
},
directives: { Outside },
directives: { Outside, GlResizeObserverDirective },
components: {
GlSearchBoxByType,
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
HeaderSearchAutocompleteItems,
DropdownKeyboardNavigation,
GlIcon,
GlToken,
},
data() {
return {
@ -50,8 +62,8 @@ export default {
};
},
computed: {
...mapState(['search', 'loading']),
...mapGetters(['searchQuery', 'searchOptions', 'autocompleteGroupedSearchOptions']),
...mapState(['search', 'loading', 'searchContext']),
...mapGetters(['searchQuery', 'searchOptions']),
searchText: {
get() {
return this.search;
@ -70,16 +82,17 @@ export default {
return Boolean(gon?.current_username);
},
showSearchDropdown() {
const hasResultsUnderMinCharacters =
this.searchText?.length === 1 ? this?.autocompleteGroupedSearchOptions?.length > 0 : true;
if (!this.showDropdown || !this.isLoggedIn) {
return false;
}
return this.showDropdown && this.isLoggedIn && hasResultsUnderMinCharacters;
return this.searchOptions?.length > 0;
},
showDefaultItems() {
return !this.searchText;
},
showShortcuts() {
return this.searchText && this.searchText?.length >= SEARCH_SHORTCUTS_MIN_CHARACTERS;
showScopes() {
return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
},
defaultIndex() {
if (this.showDefaultItems) {
@ -88,11 +101,11 @@ export default {
return FIRST_DROPDOWN_INDEX;
},
searchInputDescribeBy() {
if (this.isLoggedIn) {
return this.$options.i18n.searchInputDescribeByWithDropdown;
}
return this.$options.i18n.searchInputDescribeByNoDropdown;
},
dropdownResultsDescription() {
@ -112,8 +125,26 @@ export default {
count: this.searchOptions.length,
});
},
headerSearchActivityDescriptor() {
return this.showDropdown ? 'is-active' : 'is-not-active';
searchBarStateIndicator() {
const hasIcon =
this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon';
const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching';
const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active';
return `${isActive} ${isSearching} ${hasIcon}`;
},
searchBarItem() {
return this.searchOptions?.[0];
},
infieldHelpContent() {
return this.searchBarItem?.scope || this.searchBarItem?.description;
},
infieldHelpIcon() {
return this.searchBarItem?.icon;
},
scopeTokenTitle() {
return sprintf(this.$options.i18n.searchResultsScope, {
scope: this.infieldHelpContent,
});
},
},
methods: {
@ -127,6 +158,9 @@ export default {
this.$emit('toggleDropdown', this.showDropdown);
},
submitSearch() {
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
return null;
}
return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
},
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
@ -136,8 +170,19 @@ export default {
this.fetchAutocompleteOptions();
}
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
getTruncatedScope(scope) {
return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
},
observeTokenWidth({ contentRect: { width } }) {
const inputField = this.$refs?.searchInputBox?.$el?.querySelector('input');
if (!inputField) {
return;
}
inputField.style.paddingRight = `${width + INPUT_FIELD_PADDING}px`;
},
},
SEARCH_BOX_INDEX,
FIRST_DROPDOWN_INDEX,
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
};
@ -149,10 +194,12 @@ export default {
role="search"
:aria-label="$options.i18n.searchGitlab"
class="header-search gl-relative gl-rounded-base gl-w-full"
:class="headerSearchActivityDescriptor"
:class="searchBarStateIndicator"
data-testid="header-search-form"
>
<gl-search-box-by-type
id="search"
ref="searchInputBox"
v-model="searchText"
role="searchbox"
class="gl-z-index-1"
@ -165,7 +212,28 @@ export default {
@click="openDropdown"
@input="getAutocompleteOptions"
@keydown.enter.stop.prevent="submitSearch"
@keydown.esc.stop.prevent="closeDropdown"
/>
<gl-token
v-if="showScopes"
v-gl-resize-observer-directive="observeTokenWidth"
class="in-search-scope-help"
:view-only="true"
:title="scopeTokenTitle"
><gl-icon
v-if="infieldHelpIcon"
class="gl-mr-2"
:aria-label="infieldHelpContent"
:name="infieldHelpIcon"
:size="16"
/>{{
getTruncatedScope(
sprintf($options.i18n.searchResultsScope, {
scope: infieldHelpContent,
}),
)
}}
</gl-token>
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
searchInputDescribeBy
}}</span>
@ -187,7 +255,7 @@ export default {
<dropdown-keyboard-navigation
v-model="currentFocusIndex"
:max="searchOptions.length - 1"
:min="$options.SEARCH_BOX_INDEX"
:min="$options.FIRST_DROPDOWN_INDEX"
:default-index="defaultIndex"
@tab="closeDropdown"
/>
@ -197,7 +265,7 @@ export default {
/>
<template v-else>
<header-search-scoped-items
v-if="showShortcuts"
v-if="showScopes"
:current-focused-option="currentFocusedOption"
/>
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />

View File

@ -1,13 +1,16 @@
<script>
import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
import { s__, sprintf } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
export default {
name: 'HeaderSearchScopedItems',
components: {
GlDropdownItem,
GlDropdownDivider,
GlIcon,
GlToken,
},
props: {
currentFocusedOption: {
@ -25,12 +28,21 @@ export default {
return this.currentFocusedOption?.html_id === option.html_id;
},
ariaLabel(option) {
return sprintf(__('%{search} %{description} %{scope}'), {
return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), {
search: this.search,
description: option.description,
description: option.description || option.icon,
scope: option.scope || '',
});
},
titleLabel(option) {
return sprintf(s__('GlobalSearch|in %{scope}'), {
search: this.search,
scope: option.scope || option.description,
});
},
getTruncatedScope(scope) {
return truncate(scope, SCOPE_TOKEN_MAX_LENGTH);
},
},
};
</script>
@ -42,18 +54,30 @@ export default {
:id="option.html_id"
:ref="option.html_id"
:key="option.html_id"
class="gl-max-w-full"
:class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
:aria-selected="isOptionFocused(option)"
:aria-label="ariaLabel(option)"
tabindex="-1"
:href="option.url"
:title="titleLabel(option)"
>
<span aria-hidden="true">
"<span class="gl-font-weight-bold">{{ search }}</span
>" {{ option.description }}
<span v-if="option.scope" class="gl-font-style-italic">{{ option.scope }}</span>
<span
ref="token-text-content"
class="gl-display-flex gl-justify-content-start search-text-content gl-line-height-24 gl-align-items-start gl-flex-direction-row gl-w-full"
>
<gl-icon name="search" class="gl-flex-shrink-0 gl-mr-2 gl-relative gl-pt-2" />
<span class="gl-flex-grow-1 gl-relative">
<gl-token
class="in-dropdown-scope-help has-icon gl-flex-shrink-0 gl-relative gl-white-space-nowrap gl-float-right gl-mr-n3!"
:view-only="true"
>
<gl-icon v-if="option.icon" :name="option.icon" class="gl-mr-2" />
<span>{{ getTruncatedScope(titleLabel(option)) }}</span>
</gl-token>
{{ search }}
</span>
</span>
</gl-dropdown-item>
<gl-dropdown-divider v-if="autocompleteGroupedSearchOptions.length > 0" />
</div>
</template>

View File

@ -10,15 +10,21 @@ export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a re
export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|in all GitLab');
export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
export const MSG_IN_GROUP = s__('GlobalSearch|in group');
export const MSG_IN_GROUP = s__('GlobalSearch|group');
export const MSG_IN_PROJECT = s__('GlobalSearch|in project');
export const MSG_IN_PROJECT = s__('GlobalSearch|project');
export const GROUPS_CATEGORY = 'Groups';
export const ICON_PROJECT = 'project';
export const PROJECTS_CATEGORY = 'Projects';
export const ICON_GROUP = 'group';
export const ICON_SUBGROUP = 'subgroup';
export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
export const ISSUES_CATEGORY = 'Recent issues';
@ -39,3 +45,7 @@ export const SEARCH_SHORTCUTS_MIN_CHARACTERS = 2;
export const SEARCH_INPUT_DESCRIPTION = 'search-input-description';
export const SEARCH_RESULTS_DESCRIPTION = 'search-results-description';
export const SCOPE_TOKEN_MAX_LENGTH = 36;
export const INPUT_FIELD_PADDING = 52;

View File

@ -7,9 +7,13 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
MSG_IN_PROJECT,
MSG_IN_GROUP,
ICON_GROUP,
ICON_SUBGROUP,
ICON_PROJECT,
MSG_IN_ALL_GITLAB,
PROJECTS_CATEGORY,
GROUPS_CATEGORY,
SEARCH_SHORTCUTS_MIN_CHARACTERS,
} from '../constants';
export const searchQuery = (state) => {
@ -149,7 +153,8 @@ export const scopedSearchOptions = (state, getters) => {
options.push({
html_id: 'scoped-in-project',
scope: state.searchContext.project?.name || '',
description: MSG_IN_PROJECT,
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
url: getters.projectUrl,
});
}
@ -158,7 +163,8 @@ export const scopedSearchOptions = (state, getters) => {
options.push({
html_id: 'scoped-in-group',
scope: state.searchContext.group?.name || '',
description: MSG_IN_GROUP,
scopeCategory: GROUPS_CATEGORY,
icon: state.searchContext.group?.full_name?.includes('/') ? ICON_SUBGROUP : ICON_GROUP,
url: getters.groupUrl,
});
}
@ -190,6 +196,7 @@ export const autocompleteGroupedSearchOptions = (state) => {
results.push(groupedOptions[option.category]);
}
});
return results;
};
@ -205,5 +212,9 @@ export const searchOptions = (state, getters) => {
[],
);
if (state.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS) {
return sortedAutocompleteOptions;
}
return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
};

View File

@ -1,4 +1,5 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import { debounce } from 'lodash';
import { mapState, mapGetters, mapActions } from 'vuex';
import {
@ -45,6 +46,8 @@ const MARKDOWN_FILE_TYPE = 'markdown';
export default {
name: 'RepoEditor',
components: {
GlTabs,
GlTab,
FileAlert,
ContentViewer,
DiffViewer,
@ -121,16 +124,6 @@ export default {
isPreviewViewMode() {
return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW;
},
editTabCSS() {
return {
active: this.isEditorViewMode,
};
},
previewTabCSS() {
return {
active: this.isPreviewViewMode,
};
},
showEditor() {
return !this.shouldHideEditor && this.isEditorViewMode;
},
@ -487,28 +480,18 @@ export default {
<template>
<div id="ide" class="blob-viewer-container blob-editor-container">
<div v-if="showTabs" class="ide-mode-tabs clearfix">
<ul class="nav-links float-left border-bottom-0">
<li :class="editTabCSS">
<a
href="javascript:void(0);"
role="button"
data-testid="edit-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
>{{ __('Edit') }}</a
>
</li>
<li :class="previewTabCSS">
<a
href="javascript:void(0);"
role="button"
data-testid="preview-tab"
@click.prevent="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
>{{ previewMode.previewTitle }}</a
>
</li>
</ul>
</div>
<gl-tabs v-if="showTabs" content-class="gl-display-none">
<gl-tab
:title="__('Edit')"
data-testid="edit-tab"
@click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
/>
<gl-tab
:title="previewMode.previewTitle"
data-testid="preview-tab"
@click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
/>
</gl-tabs>
<file-alert v-if="alertKey" :alert-key="alertKey" />
<file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div

View File

@ -70,6 +70,7 @@ import {
UPDATED_DESC,
urlSortParams,
} from '../constants';
import eventHub from '../eventhub';
import reorderIssuesMutation from '../queries/reorder_issues.mutation.graphql';
import searchLabelsQuery from '../queries/search_labels.query.graphql';
@ -172,6 +173,7 @@ export default {
showBulkEditSidebar: false,
sortKey: CREATED_DESC,
state: IssuableStates.Opened,
pageSize: PAGE_SIZE,
};
},
apollo: {
@ -423,6 +425,10 @@ export default {
showPaginationControls() {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
},
showPageSizeControls() {
/** only show page size controls when the tab count is greater than the default/minimum page size control i.e 20 in this case */
return this.currentTabCount > PAGE_SIZE;
},
sortOptions() {
return getSortOptions(this.hasIssueWeightsFeature, this.hasBlockedIssuesFeature);
},
@ -445,8 +451,8 @@ export default {
...this.urlFilterParams,
first_page_size: this.pageParams.firstPageSize,
last_page_size: this.pageParams.lastPageSize,
page_after: this.pageParams.afterCursor,
page_before: this.pageParams.beforeCursor,
page_after: this.pageParams.afterCursor ?? undefined,
page_before: this.pageParams.beforeCursor ?? undefined,
};
},
},
@ -555,7 +561,7 @@ export default {
},
handleClickTab(state) {
if (this.state !== state) {
this.pageParams = getInitialPageParams(this.sortKey);
this.pageParams = getInitialPageParams(this.pageSize);
}
this.state = state;
@ -570,7 +576,7 @@ export default {
return;
}
this.pageParams = getInitialPageParams(this.sortKey);
this.pageParams = getInitialPageParams(this.pageSize);
this.filterTokens = filter;
this.$router.push({ query: this.urlParams });
@ -578,7 +584,7 @@ export default {
handleNextPage() {
this.pageParams = {
afterCursor: this.pageInfo.endCursor,
firstPageSize: PAGE_SIZE,
firstPageSize: this.pageSize,
};
scrollUp();
@ -587,7 +593,7 @@ export default {
handlePreviousPage() {
this.pageParams = {
beforeCursor: this.pageInfo.startCursor,
lastPageSize: PAGE_SIZE,
lastPageSize: this.pageSize,
};
scrollUp();
@ -636,7 +642,7 @@ export default {
}
if (this.sortKey !== sortKey) {
this.pageParams = getInitialPageParams(sortKey);
this.pageParams = getInitialPageParams(this.pageSize);
}
this.sortKey = sortKey;
@ -676,6 +682,17 @@ export default {
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
},
handlePageSizeChange(newPageSize) {
/** make sure the page number is preserved so that the current context is not lost* */
const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
const pageNumberSize = lastPageSize ? 'lastPageSize' : 'firstPageSize';
/** depending upon what page or page size we are dynamically set pageParams * */
this.pageParams[pageNumberSize] = newPageSize;
this.pageSize = newPageSize;
scrollUp();
this.$router.push({ query: this.urlParams });
},
updateData(sortValue) {
const firstPageSize = getParameterByName(PARAM_FIRST_PAGE_SIZE);
const lastPageSize = getParameterByName(PARAM_LAST_PAGE_SIZE);
@ -708,7 +725,7 @@ export default {
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search);
this.pageParams = getInitialPageParams(
sortKey,
this.pageSize,
isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined,
isPositiveInteger(lastPageSize) ? parseInt(lastPageSize, 10) : undefined,
pageAfter,
@ -744,8 +761,10 @@ export default {
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
:default-page-size="pageSize"
sync-filter-and-sort
use-keyset-pagination
:show-page-size-change-controls="showPageSizeControls"
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
@click-tab="handleClickTab"
@ -756,6 +775,7 @@ export default {
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
@page-size-change="handlePageSizeChange"
>
<template #nav-actions>
<gl-button

View File

@ -21,7 +21,6 @@ import {
MILESTONE_DUE_DESC,
NORMAL_FILTER,
PAGE_SIZE,
PAGE_SIZE_MANUAL,
PARAM_ASSIGNEE_ID,
POPULARITY_ASC,
POPULARITY_DESC,
@ -49,8 +48,8 @@ import {
} from './constants';
export const getInitialPageParams = (
sortKey,
firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
pageSize,
firstPageSize = pageSize ?? PAGE_SIZE,
lastPageSize,
afterCursor,
beforeCursor,

View File

@ -1,280 +0,0 @@
<script>
import {
GlSprintf,
GlAlert,
GlLink,
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
GlInfiniteScroll,
} from '@gitlab/ui';
import { throttle } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import { timeRangeFromUrl } from '~/monitoring/utils';
import { defaultTimeRange } from '~/vue_shared/constants';
import { formatDate } from '../utils';
import LogAdvancedFilters from './log_advanced_filters.vue';
import LogControlButtons from './log_control_buttons.vue';
import LogSimpleFilters from './log_simple_filters.vue';
export default {
components: {
GlSprintf,
GlLink,
GlAlert,
GlDropdown,
GlDropdownSectionHeader,
GlDropdownItem,
GlInfiniteScroll,
LogSimpleFilters,
LogAdvancedFilters,
LogControlButtons,
},
props: {
environmentName: {
type: String,
required: false,
default: '',
},
currentPodName: {
type: [String, null],
required: false,
default: null,
},
environmentsPath: {
type: String,
required: false,
default: '',
},
clusterApplicationsDocumentationPath: {
type: String,
required: true,
},
clustersPath: {
type: String,
required: true,
},
},
data() {
return {
isElasticStackCalloutDismissed: false,
scrollDownButtonDisabled: true,
isDeprecationNoticeDismissed: false,
};
},
computed: {
...mapState('environmentLogs', ['environments', 'timeRange', 'logs', 'pods']),
...mapGetters('environmentLogs', ['trace', 'showAdvancedFilters']),
showLoader() {
return this.logs.isLoading;
},
shouldShowElasticStackCallout() {
return !(
this.environments.isLoading ||
this.isElasticStackCalloutDismissed ||
this.showAdvancedFilters
);
},
},
mounted() {
this.setInitData({
timeRange: timeRangeFromUrl() || defaultTimeRange,
environmentName: this.environmentName,
podName: this.currentPodName,
});
this.fetchEnvironments(this.environmentsPath);
},
methods: {
...mapActions('environmentLogs', [
'setInitData',
'showEnvironment',
'fetchEnvironments',
'refreshPodLogs',
'fetchMoreLogsPrepend',
'dismissRequestEnvironmentsError',
'dismissInvalidTimeRangeWarning',
'dismissRequestLogsError',
]),
isCurrentEnvironment(envName) {
return envName === this.environments.current;
},
topReached() {
if (!this.logs.isLoading) {
this.fetchMoreLogsPrepend();
}
},
scrollDown() {
this.$refs.infiniteScroll.scrollDown();
},
scroll: throttle(function scrollThrottled({ target = {} }) {
const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
}, 200),
formatDate,
},
};
</script>
<template>
<div class="environment-logs-viewer d-flex flex-column py-3">
<gl-alert
v-if="shouldShowElasticStackCallout"
ref="elasticsearchNotice"
class="mb-3"
@dismiss="isElasticStackCalloutDismissed = true"
>
{{
s__(
'Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search.',
)
}}
<a :href="clusterApplicationsDocumentationPath">
<strong>
{{ __('View Documentation') }}
</strong>
</a>
</gl-alert>
<gl-alert
v-if="environments.fetchError"
class="mb-3"
variant="danger"
@dismiss="dismissRequestEnvironmentsError"
>
{{ s__('Metrics|There was an error fetching the environments data, please try again') }}
</gl-alert>
<gl-alert
v-if="timeRange.invalidWarning"
class="mb-3"
variant="warning"
@dismiss="dismissInvalidTimeRangeWarning"
>
{{ s__('Metrics|Invalid time range, please verify.') }}
</gl-alert>
<gl-alert
v-if="!isDeprecationNoticeDismissed"
:title="s__('Deprecations|Feature deprecation and removal')"
class="mb-3"
variant="danger"
@dismiss="isDeprecationNoticeDismissed = true"
>
<gl-sprintf
:message="
s__(
'Deprecations|The logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0.',
)
"
>
<template #epic="{ content }">
<gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/7188" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
<gl-sprintf
:message="
s__(
'Deprecations|For information on a possible replacement %{epicStart} learn more about Opstrace %{epicEnd}.',
)
"
>
<template #epic="{ content }">
<gl-link href="https://gitlab.com/groups/gitlab-org/-/epics/6976" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<gl-alert
v-if="logs.fetchError"
class="mb-3"
variant="danger"
@dismiss="dismissRequestLogsError"
>
{{ s__('Environments|There was an error fetching the logs. Please try again.') }}
</gl-alert>
<div class="top-bar d-md-flex border bg-secondary-50 pt-2 pr-1 pb-0 pl-2">
<div class="flex-grow-0">
<gl-dropdown
id="environments-dropdown"
:text="environments.current"
:disabled="environments.isLoading"
class="gl-mr-3 gl-mb-3 gl-display-flex gl-md-display-block js-environments-dropdown"
>
<gl-dropdown-section-header>
{{ s__('Environments|Environments') }}
</gl-dropdown-section-header>
<gl-dropdown-item
v-for="env in environments.options"
:key="env.id"
:is-check-item="true"
:is-checked="isCurrentEnvironment(env.name)"
@click="showEnvironment(env.name)"
>
{{ env.name }}
</gl-dropdown-item>
</gl-dropdown>
</div>
<log-advanced-filters
v-if="showAdvancedFilters"
ref="log-advanced-filters"
class="d-md-flex flex-grow-1 min-width-0"
:disabled="environments.isLoading"
/>
<log-simple-filters
v-else
ref="log-simple-filters"
class="d-md-flex flex-grow-1 min-width-0"
:disabled="environments.isLoading"
/>
<log-control-buttons
ref="scrollButtons"
class="flex-grow-0 pr-2 mb-2 controllers gl-display-inline-flex"
:scroll-down-button-disabled="scrollDownButtonDisabled"
@refresh="refreshPodLogs()"
@scrollDown="scrollDown"
/>
</div>
<gl-infinite-scroll
ref="infiniteScroll"
class="log-lines overflow-auto flex-grow-1 min-height-0"
:fetched-items="logs.lines.length"
@topReached="topReached"
@scroll="scroll"
>
<template #items>
<pre
ref="logTrace"
class="build-log"
><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>{{trace}}
</code></pre>
</template>
<template #default><div></div></template>
</gl-infinite-scroll>
<div ref="logFooter" class="py-2 px-3 text-white bg-secondary-900">
<gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
<template #start>{{ formatDate(timeRange.current.start) }}</template>
<template #end>{{ formatDate(timeRange.current.end) }}</template>
</gl-sprintf>
<gl-sprintf
v-if="!logs.isComplete"
:message="s__('Environments|Currently showing %{fetched} results.')"
>
<template #fetched>{{ logs.lines.length }}</template>
</gl-sprintf>
<template v-else> {{ s__('Environments|Currently showing all results.') }}</template>
</div>
</div>
</template>

View File

@ -1,99 +0,0 @@
<script>
import { GlFilteredSearch } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import { timeRanges } from '~/vue_shared/constants';
import { TOKEN_TYPE_POD_NAME } from '../constants';
import TokenWithLoadingState from './tokens/token_with_loading_state.vue';
export default {
components: {
GlFilteredSearch,
DateTimePicker,
},
props: {
disabled: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
timeRanges,
};
},
computed: {
...mapState('environmentLogs', ['timeRange', 'pods', 'logs']),
timeRangeModel: {
get() {
return this.timeRange.selected;
},
set(val) {
this.setTimeRange(val);
},
},
/**
* Token options.
*
* Returns null when no pods are present, so suggestions are displayed in the token
*/
podOptions() {
if (this.pods.options.length) {
return this.pods.options.map((podName) => ({ value: podName, title: podName }));
}
return null;
},
tokens() {
return [
{
icon: 'pod',
type: TOKEN_TYPE_POD_NAME,
title: s__('Environments|Pod name'),
token: TokenWithLoadingState,
operators: OPERATOR_IS_ONLY,
unique: true,
options: this.podOptions,
loading: this.logs.isLoading,
noOptionsText: s__('Environments|No pods to display'),
},
];
},
},
methods: {
...mapActions('environmentLogs', ['showFilteredLogs', 'setTimeRange']),
filteredSearchSubmit(filters) {
this.showFilteredLogs(filters);
},
},
};
</script>
<template>
<div>
<div class="mb-2 pr-2 flex-grow-1 min-width-0">
<gl-filtered-search
:placeholder="__('Search')"
:clear-button-title="__('Clear')"
:close-button-title="__('Close')"
class="gl-h-32"
:disabled="disabled || logs.isLoading"
:available-tokens="tokens"
@submit="filteredSearchSubmit"
/>
</div>
<date-time-picker
ref="dateTimePicker"
v-model="timeRangeModel"
:disabled="disabled"
:options="timeRanges"
class="mb-2 gl-h-32 pr-2 d-block date-time-picker-wrapper"
right
/>
</div>
</template>

View File

@ -1,16 +0,0 @@
export const dateFormatMask = 'mmm dd HH:MM:ss.l';
export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
export const tracking = {
USED_SEARCH_BAR: 'used_search_bar',
POD_LOG_CHANGED: 'pod_log_changed',
TIME_RANGE_SET: 'time_range_set',
ENVIRONMENT_SELECTED: 'environment_selected',
REFRESH_POD_LOGS: 'refresh_pod_logs',
MANAGED_APP_SELECTED: 'managed_app_selected',
};
export const logExplorerOptions = {
environments: 'environments',
};

View File

@ -1,24 +0,0 @@
import Vue from 'vue';
import { getParameterValues } from '~/lib/utils/url_utility';
import LogViewer from './components/environment_logs.vue';
import store from './stores';
export default (props = {}) => {
const el = document.getElementById('environment-logs');
const [currentPodName] = getParameterValues('pod_name');
// eslint-disable-next-line no-new
new Vue({
el,
store,
render(createElement) {
return createElement(LogViewer, {
props: {
...el.dataset,
currentPodName,
...props,
},
});
},
});
};

View File

@ -1,18 +0,0 @@
import Tracking from '~/tracking';
/**
* The value of 1 in count, means there was one action performed
* related to the tracked action, in either of the following categories
* 1. Refreshing the logs
* 2. Select an environment
* 3. Change the time range
* 4. Use the search bar
*/
const trackLogs = (label) =>
Tracking.event(document.body.dataset.page, 'logs_view', {
label,
property: 'count',
value: 1,
});
export default trackLogs;

View File

@ -1,174 +0,0 @@
import axios from '~/lib/utils/axios_utils';
import { backOff } from '~/lib/utils/common_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import httpStatusCodes from '~/lib/utils/http_status';
import { TOKEN_TYPE_POD_NAME, tracking, logExplorerOptions } from '../constants';
import trackLogs from '../logs_tracking_helper';
import * as types from './mutation_types';
const requestUntilData = (url, params) =>
backOff((next, stop) => {
axios
.get(url, { params })
.then((res) => {
if (res.status === httpStatusCodes.ACCEPTED) {
next();
return;
}
stop(res);
})
.catch((err) => {
stop(err);
});
});
const requestLogsUntilData = ({ commit, state }) => {
const params = {};
const type = logExplorerOptions.environments;
const selectedObj = state[type].options.find(({ name }) => name === state[type].current);
const path = selectedObj.logs_api_path;
if (state.pods.current) {
params.pod_name = state.pods.current;
}
if (state.search) {
params.search = state.search;
}
if (state.timeRange.current) {
try {
const { start, end } = convertToFixedRange(state.timeRange.current);
params.start_time = start;
params.end_time = end;
} catch {
commit(types.SHOW_TIME_RANGE_INVALID_WARNING);
}
}
if (state.logs.cursor) {
params.cursor = state.logs.cursor;
}
return requestUntilData(path, params);
};
/**
* Converts filters emitted by the component, e.g. a filterered-search
* to parameters to be applied to the filters of the store
* @param {Array} filters - List of strings or objects to filter by.
* @returns {Object} - An object with `search` and `podName` keys.
*/
const filtersToParams = (filters = []) => {
// Strings become part of the `search`
const search = filters
.filter((f) => typeof f === 'string')
.join(' ')
.trim();
// null podName to show all pods
const podName = filters.find((f) => f?.type === TOKEN_TYPE_POD_NAME)?.value?.data ?? null;
return { search, podName };
};
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
commit(types.SET_TIME_RANGE, timeRange);
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
commit(types.SET_CURRENT_POD_NAME, podName);
};
export const showFilteredLogs = ({ dispatch, commit }, filters = []) => {
const { podName, search } = filtersToParams(filters);
commit(types.SET_CURRENT_POD_NAME, podName);
commit(types.SET_SEARCH, search);
dispatch('fetchLogs', tracking.USED_SEARCH_BAR);
};
export const showPodLogs = ({ dispatch, commit }, podName) => {
commit(types.SET_CURRENT_POD_NAME, podName);
dispatch('fetchLogs', tracking.POD_LOG_CHANGED);
};
export const setTimeRange = ({ dispatch, commit }, timeRange) => {
commit(types.SET_TIME_RANGE, timeRange);
dispatch('fetchLogs', tracking.TIME_RANGE_SET);
};
export const showEnvironment = ({ dispatch, commit }, environmentName) => {
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
};
export const refreshPodLogs = ({ dispatch, commit }) => {
commit(types.REFRESH_POD_LOGS);
dispatch('fetchLogs', tracking.REFRESH_POD_LOGS);
};
/**
* Fetch environments data and initial logs
* @param {Object} store
* @param {String} environmentsPath
*/
export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
commit(types.REQUEST_ENVIRONMENTS_DATA);
return axios
.get(environmentsPath)
.then(({ data }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
})
.catch(() => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
});
};
export const fetchLogs = ({ commit, state }, trackingLabel) => {
commit(types.REQUEST_LOGS_DATA);
return requestLogsUntilData({ commit, state })
.then(({ data }) => {
const { pod_name, pods, logs, cursor } = data;
if (logs && logs.length > 0) {
trackLogs(trackingLabel);
}
commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
commit(types.SET_CURRENT_POD_NAME, pod_name);
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
})
.catch(() => {
commit(types.RECEIVE_PODS_DATA_ERROR);
commit(types.RECEIVE_LOGS_DATA_ERROR);
});
};
export const fetchMoreLogsPrepend = ({ commit, state }) => {
if (state.logs.isComplete) {
// return when all logs are loaded
return Promise.resolve();
}
commit(types.REQUEST_LOGS_DATA_PREPEND);
return requestLogsUntilData({ commit, state })
.then(({ data }) => {
const { logs, cursor } = data;
commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor });
})
.catch(() => {
commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR);
});
};
export const dismissRequestEnvironmentsError = ({ commit }) => {
commit(types.HIDE_REQUEST_ENVIRONMENTS_ERROR);
};
export const dismissRequestLogsError = ({ commit }) => {
commit(types.HIDE_REQUEST_LOGS_ERROR);
};
export const dismissInvalidTimeRangeWarning = ({ commit }) => {
commit(types.HIDE_TIME_RANGE_INVALID_WARNING);
};

View File

@ -1,14 +0,0 @@
import { formatDate } from '../utils';
const mapTrace = ({ timestamp = null, pod = '', message = '' }) =>
[timestamp ? formatDate(timestamp) : '', pod, message].join(' | ');
export const trace = (state) => state.logs.lines.map(mapTrace).join('\n');
export const showAdvancedFilters = (state) => {
const environment = state.environments.options.find(
({ name }) => name === state.environments.current,
);
return Boolean(environment?.enable_advanced_logs_querying);
};

View File

@ -1,23 +0,0 @@
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
modules: {
environmentLogs: {
namespaced: true,
actions,
mutations,
state: state(),
getters,
},
},
});
export default createStore;

View File

@ -1,4 +0,0 @@
import dateFormat from 'dateformat';
import { dateFormatMask } from './constants';
export const formatDate = (timestamp) => dateFormat(timestamp, dateFormatMask);

View File

@ -0,0 +1,37 @@
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export const PAGE_SIZES = [20, 50, 100];
export default {
components: { GlDropdown, GlDropdownItem },
props: {
value: {
type: Number,
required: true,
},
},
methods: {
emitInput(pageSize) {
this.$emit('input', pageSize);
},
getPageSizeText(pageSize) {
return sprintf(s__('SecurityReports|Show %{pageSize} items'), { pageSize });
},
},
PAGE_SIZES,
};
</script>
<template>
<gl-dropdown :text="getPageSizeText(value)" right menu-class="gl-w-auto! gl-min-w-0">
<gl-dropdown-item
v-for="pageSize in $options.PAGE_SIZES"
:key="pageSize"
@click="emitInput(pageSize)"
>
<span class="gl-white-space-nowrap">{{ getPageSizeText(pageSize) }}</span>
</gl-dropdown-item>
</gl-dropdown>
</template>

View File

@ -1,11 +1,13 @@
<script>
import { GlAlert, GlKeysetPagination, GlSkeletonLoader, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { DEFAULT_SKELETON_COUNT } from '../constants';
import { DEFAULT_SKELETON_COUNT, PAGE_SIZE_STORAGE_KEY } from '../constants';
import IssuableBulkEditSidebar from './issuable_bulk_edit_sidebar.vue';
import IssuableItem from './issuable_item.vue';
import IssuableTabs from './issuable_tabs.vue';
@ -29,6 +31,8 @@ export default {
IssuableBulkEditSidebar,
GlPagination,
VueDraggable,
PageSizeSelector,
LocalStorageSync,
},
props: {
namespace: {
@ -173,6 +177,11 @@ export default {
required: false,
default: false,
},
showPageSizeChangeControls: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -262,7 +271,11 @@ export default {
handleVueDraggableUpdate({ newIndex, oldIndex }) {
this.$emit('reorder', { newIndex, oldIndex });
},
handlePageSizeChange(newPageSize) {
this.$emit('page-size-change', newPageSize);
},
},
PAGE_SIZE_STORAGE_KEY,
};
</script>
@ -353,24 +366,38 @@ export default {
<slot v-else name="empty-state"></slot>
</template>
<div v-if="showPaginationControls && useKeysetPagination" class="gl-text-center gl-mt-3">
<div class="gl-text-center gl-mt-6 gl-relative">
<gl-keyset-pagination
v-if="showPaginationControls && useKeysetPagination"
:has-next-page="hasNextPage"
:has-previous-page="hasPreviousPage"
@next="$emit('next-page')"
@prev="$emit('previous-page')"
/>
<gl-pagination
v-else-if="showPaginationControls"
:per-page="defaultPageSize"
:total-items="totalItems"
:value="currentPage"
:prev-page="previousPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="$emit('page-change', $event)"
/>
<local-storage-sync
v-if="showPageSizeChangeControls"
:value="defaultPageSize"
:storage-key="$options.PAGE_SIZE_STORAGE_KEY"
@input="handlePageSizeChange"
>
<page-size-selector
:value="defaultPageSize"
class="gl-absolute gl-right-0"
@input="handlePageSizeChange"
/>
</local-storage-sync>
</div>
<gl-pagination
v-else-if="showPaginationControls"
:per-page="defaultPageSize"
:total-items="totalItems"
:value="currentPage"
:prev-page="previousPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
@input="$emit('page-change', $event)"
/>
</div>
</template>

View File

@ -56,3 +56,5 @@ export const IssuableTypes = {
export const DEFAULT_PAGE_SIZE = 20;
export const DEFAULT_SKELETON_COUNT = 5;
export const PAGE_SIZE_STORAGE_KEY = 'issuable_list_page_size';

View File

@ -64,10 +64,6 @@
border-color: var(--ide-input-border, $gray-darkest);
}
}
a.gl-tab-nav-item-active {
box-shadow: inset 0 -2px 0 0 var(--ide-input-border, $gray-darkest);
}
}
.drag-handle:hover {

View File

@ -4,7 +4,8 @@ $search-sidebar-min-width: 240px;
$search-sidebar-max-width: 300px;
$search-input-field-x-min-width: 200px;
$search-input-field-min-width: 320px;
$search-input-field-max-width: 600px;
$search-input-field-max-width: 640px;
$search-keyboard-shortcut: '/';
$border-radius-medium: 3px;
@ -67,54 +68,58 @@ input[type='checkbox']:hover {
}
}
// This is a temporary workaround!
// the button in GitLab UI Search components need to be updated to not be the small size
// see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540
.header-search .gl-search-box-by-type-clear.btn-sm {
padding: 0.5rem !important;
}
.header-search {
min-width: $search-input-field-min-width;
// This is a temporary workaround!
// the button in GitLab UI Search components need to be updated to not be the small size
// see in Figma: https://www.figma.com/file/qEddyqCrI7kPSBjGmwkZzQ/Component-library?node-id=43905%3A45540
.gl-search-box-by-type-clear.btn-sm {
padding: 0.5rem !important;
}
@include media-breakpoint-between(md, lg) {
min-width: $search-input-field-x-min-width;
}
input,
svg {
transition: border-color ease-in-out $default-transition-duration,
background-color ease-in-out $default-transition-duration;
&.is-active {
&.is-searching {
.in-search-scope-help {
position: absolute;
top: $gl-spacing-scale-2;
right: 2.125rem;
z-index: 2;
}
}
}
&.is-not-searching {
.in-search-scope-help {
display: none;
}
}
&.is-not-active {
.btn.gl-clear-icon-button {
.btn.gl-clear-icon-button,
.in-search-scope-help {
display: none;
}
&::after {
content: '/';
display: inline-block;
content: $search-keyboard-shortcut;
transform: translateY(calc(50% - #{$gl-spacing-scale-2}));
position: absolute;
top: 0;
right: 8px;
transform: translateY(calc(50% - 4px));
padding: 4px 5px;
right: $gl-spacing-scale-3;
padding: $gl-spacing-scale-2 5px;
font-size: $gl-font-size-small;
font-family: $monospace-font;
line-height: 1;
vertical-align: middle;
border-width: 0;
border-style: solid;
border-image: none;
border-radius: $border-radius-medium;
box-shadow: none;
white-space: pre-wrap;
box-sizing: border-box;
// Safari
word-wrap: break-word;
overflow-wrap: break-word;
word-break: keep-all;
}
}
}

View File

@ -1504,7 +1504,7 @@ svg.s16 {
vertical-align: -3px;
}
.header-content .header-search-new {
max-width: 600px;
max-width: 640px;
}
.header-search {
min-width: 320px;
@ -1516,27 +1516,20 @@ svg.s16 {
}
.header-search.is-not-active::after {
content: "/";
display: inline-block;
transform: translateY(calc(50% - 0.25rem));
position: absolute;
top: 0;
right: 8px;
transform: translateY(calc(50% - 4px));
padding: 4px 5px;
right: 0.5rem;
padding: 0.25rem 5px;
font-size: 12px;
font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
"Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
line-height: 1;
vertical-align: middle;
border-width: 0;
border-style: solid;
border-image: none;
border-radius: 3px;
box-shadow: none;
white-space: pre-wrap;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: keep-all;
}
.search {
margin: 0 8px;

View File

@ -1489,7 +1489,7 @@ svg.s16 {
vertical-align: -3px;
}
.header-content .header-search-new {
max-width: 600px;
max-width: 640px;
}
.header-search {
min-width: 320px;
@ -1501,27 +1501,20 @@ svg.s16 {
}
.header-search.is-not-active::after {
content: "/";
display: inline-block;
transform: translateY(calc(50% - 0.25rem));
position: absolute;
top: 0;
right: 8px;
transform: translateY(calc(50% - 4px));
padding: 4px 5px;
right: 0.5rem;
padding: 0.25rem 5px;
font-size: 12px;
font-family: "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas",
"Ubuntu Mono", "Courier New", "andale mono", "lucida console", monospace;
line-height: 1;
vertical-align: middle;
border-width: 0;
border-style: solid;
border-image: none;
border-radius: 3px;
box-shadow: none;
white-space: pre-wrap;
box-sizing: border-box;
word-wrap: break-word;
overflow-wrap: break-word;
word-break: keep-all;
}
.search {
margin: 0 8px;

View File

@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:issue_assignees_widget, @project)
push_frontend_feature_flag(:realtime_labels, project)
push_frontend_feature_flag(:refactor_security_extension, @project)
push_frontend_feature_flag(:refactor_code_quality_inline_findings, project)
push_frontend_feature_flag(:mr_attention_requests, current_user)
push_frontend_feature_flag(:moved_mr_sidebar, project)
push_frontend_feature_flag(:paginated_mr_discussions, project)

View File

@ -7,7 +7,8 @@ module Projects
'type' => 'object',
'properties' => {
'project_id' => { 'type' => 'integer' },
'namespace_id' => { 'type' => 'integer' }
'namespace_id' => { 'type' => 'integer' },
'root_namespace_id' => { 'type' => 'integer' }
},
'required' => %w[project_id namespace_id]
}

View File

@ -170,7 +170,7 @@ module SearchHelper
# search_context exposes a bit too much data to the frontend, this controls what data we share and when.
def header_search_context
{}.tap do |hash|
hash[:group] = { id: search_context.group.id, name: search_context.group.name } if search_context.for_group?
hash[:group] = { id: search_context.group.id, name: search_context.group.name, full_name: search_context.group.full_name } if search_context.for_group?
hash[:group_metadata] = search_context.group_metadata if search_context.for_group?
hash[:project] = { id: search_context.project.id, name: search_context.project.name } if search_context.for_project?

View File

@ -36,8 +36,10 @@ module Issues
end
def branches_with_iid_of(issue)
project.repository.branch_names.select do |branch|
branch =~ /\A#{issue.iid}-(?!\d+-stable)/i
branch_name_regex = /\A#{issue.iid}-(?!\d+-stable)/i
project.repository.search_branch_names("#{issue.iid}-*").select do |branch|
branch.match?(branch_name_regex)
end
end
end

View File

@ -16,7 +16,7 @@ module MergeRequests
mark_pending_todos_as_done(merge_request)
execute_approval_hooks(merge_request, current_user)
remove_attention_requested(merge_request)
merge_request_activity_counter.track_approve_mr_action(user: current_user)
merge_request_activity_counter.track_approve_mr_action(user: current_user, merge_request: merge_request)
success
end

View File

@ -263,8 +263,12 @@ module Projects
end
def publish_project_deleted_event_for(project)
data = { project_id: project.id, namespace_id: project.namespace_id }
event = Projects::ProjectDeletedEvent.new(data: data)
event = Projects::ProjectDeletedEvent.new(data: {
project_id: project.id,
namespace_id: project.namespace_id,
root_namespace_id: project.root_namespace.id
})
Gitlab::EventStore.publish(event)
end
end

View File

@ -53,12 +53,8 @@ module BulkImports
pipeline_tracker.update!(status_event: 'start', jid: jid)
pipeline_tracker.pipeline_class.new(context).run
pipeline_tracker.finish!
rescue BulkImports::NetworkError => e
if e.retriable?(pipeline_tracker)
retry_tracker(e)
else
fail_tracker(e)
end
rescue BulkImports::RetryPipelineError => e
retry_tracker(e)
rescue StandardError => e
fail_tracker(e)
end

View File

@ -0,0 +1,26 @@
---
description: Merge request approvals
category: merge_requests
action: i_code_review_user_approve_mr
label_description:
property_description:
value_description:
extra_properties:
identifiers:
- project
- user
- namespace
product_section: 'TBD'
product_stage: create
product_group: code_review
product_category: code_review
milestone: "15.2"
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91493
distributions:
- ce
- ee
tiers:
- free
- premium
- ultimate

View File

@ -0,0 +1,8 @@
---
name: refactor_code_quality_inline_findings
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88576
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/364198
milestone: '15.1'
type: development
group: group::static analysis
default_enabled: false

View File

@ -13,29 +13,25 @@ disable or enable this feature manually by following
[these instructions](#disabling-or-enabling-the-automatic-background-verification).
Automatic background verification ensures that the transferred data matches a
calculated checksum. If the checksum of the data on the **primary** node matches checksum of the
data on the **secondary** node, the data transferred successfully. Following a planned failover,
calculated checksum. If the checksum of the data on the **primary** site matches checksum of the
data on the **secondary** site, the data transferred successfully. Following a planned failover,
any corrupted data may be **lost**, depending on the extent of the corruption.
If verification fails on the **primary** node, this indicates Geo is replicating a corrupted object.
You can restore it from backup or remove it from the **primary** node to resolve the issue.
If verification fails on the **primary** site, this indicates Geo is replicating a corrupted object.
You can restore it from backup or remove it from the **primary** site to resolve the issue.
If verification succeeds on the **primary** node but fails on the **secondary** node,
If verification succeeds on the **primary** site but fails on the **secondary** site,
this indicates that the object was corrupted during the replication process.
Geo actively try to correct verification failures marking the repository to
be resynced with a back-off period. If you want to reset the verification for
these failures, so you should follow [these instructions](background_verification.md#reset-verification-for-projects-where-verification-has-failed).
If verification is lagging significantly behind replication, consider giving
the node more time before scheduling a planned failover.
the site more time before scheduling a planned failover.
## Disabling or enabling the automatic background verification
Run the following commands in a Rails console on the **primary** node:
```shell
gitlab-rails console
```
Run the following commands in a [Rails console](../../operations/rails_console.md) on a **Rails node on the primary** site.
To check if automatic background verification is enabled:
@ -57,33 +53,33 @@ Feature.enable('geo_repository_verification')
## Repository verification
On the **primary** node:
On the **primary** site:
1. On the top bar, select **Menu > Admin**.
1. On the left sidebar, select **Geo > Nodes**.
1. Expand **Verification information** tab for that node to view automatic checksumming
1. On the left sidebar, select **Geo > Sites**.
1. Expand **Verification information** tab for that site to view automatic checksumming
status for repositories and wikis. Successes are shown in green, pending work
in gray, and failures in red.
![Verification status](img/verification_status_primary_v14_0.png)
On the **secondary** node:
On the **secondary** site:
1. On the top bar, select **Menu > Admin**.
1. On the left sidebar, select **Geo > Nodes**.
1. Expand **Verification information** tab for that node to view automatic checksumming
1. On the left sidebar, select **Geo > Sites**.
1. Expand **Verification information** tab for that site to view automatic checksumming
status for repositories and wikis. Successes are shown in green, pending work
in gray, and failures in red.
![Verification status](img/verification_status_secondary_v14_0.png)
## Using checksums to compare Geo nodes
## Using checksums to compare Geo sites
To check the health of Geo **secondary** nodes, we use a checksum over the list of
To check the health of Geo **secondary** sites, we use a checksum over the list of
Git references and their values. The checksum includes `HEAD`, `heads`, `tags`,
`notes`, and GitLab-specific references to ensure true consistency. If two nodes
`notes`, and GitLab-specific references to ensure true consistency. If two sites
have the same checksum, then they definitely hold the same references. We compute
the checksum for every node after every update to make sure that they are all
the checksum for every site after every update to make sure that they are all
in sync.
## Repository re-verification
@ -95,22 +91,17 @@ data. The default and recommended re-verification interval is 7 days, though
an interval as short as 1 day can be set. Shorter intervals reduce risk but
increase load and vice versa.
On the **primary** node:
On the **primary** site:
1. On the top bar, select **Menu > Admin**.
1. On the left sidebar, select **Geo > Nodes**.
1. Select **Edit** for the **primary** node to customize the minimum
1. On the left sidebar, select **Geo > Sites**.
1. Select **Edit** for the **primary** site to customize the minimum
re-verification interval:
![Re-verification interval](img/reverification-interval.png)
The automatic background re-verification is enabled by default, but you can
disable if you need. Run the following commands in a Rails console on the
**primary** node:
```shell
gitlab-rails console
```
disable if you need. Run the following commands in a [Rails console](../../operations/rails_console.md) on a **Rails node on the primary** site:
To disable automatic background re-verification:
@ -126,11 +117,13 @@ Feature.enable('geo_repository_reverification')
## Reset verification for projects where verification has failed
Geo actively try to correct verification failures marking the repository to
Geo actively tries to correct verification failures marking the repository to
be resynced with a back-off period. If you want to reset them manually, this
Rake task marks projects where verification has failed or the checksum mismatch
to be resynced without the back-off period:
Run the appropriate commands on a **Rails node on the primary** site.
For repositories:
```shell
@ -145,9 +138,9 @@ sudo gitlab-rake geo:verification:wiki:reset
## Reconcile differences with checksum mismatches
If the **primary** and **secondary** nodes have a checksum verification mismatch, the cause may not be apparent. To find the cause of a checksum mismatch:
If the **primary** and **secondary** sites have a checksum verification mismatch, the cause may not be apparent. To find the cause of a checksum mismatch:
1. On the **primary** node:
1. On the **primary** site:
1. On the top bar, select **Menu > Admin**.
1. On the left sidebar, select **Overview > Projects**.
1. Find the project that you want to check the checksum differences and
@ -157,31 +150,32 @@ If the **primary** and **secondary** nodes have a checksum verification mismatch
![Project administration page](img/checksum-differences-admin-project-page.png)
1. Go to the project's repository directory on both **primary** and **secondary** nodes
(the path is usually `/var/opt/gitlab/git-data/repositories`). If `git_data_dirs`
1. On a **Gitaly node on the primary** site and a **Gitaly node on the secondary** site, go to the project's repository directory. If using Gitaly Cluster, [check that it is in a healthy state](../../gitaly/troubleshooting.md#check-cluster-health) prior to running these commands.
The default path is `/var/opt/gitlab/git-data/repositories`. If `git_data_dirs`
is customized, check the directory layout on your server to be sure:
```shell
cd /var/opt/gitlab/git-data/repositories
```
1. Run the following command on the **primary** site, redirecting the output to a file:
1. Run the following command on the **primary** node, redirecting the output to a file:
```shell
git show-ref --head | grep -E "HEAD|(refs/(heads|tags|keep-around|merge-requests|environments|notes)/)" > primary-site-refs
```
```shell
git show-ref --head | grep -E "HEAD|(refs/(heads|tags|keep-around|merge-requests|environments|notes)/)" > primary-node-refs
```
1. Run the following command on the **secondary** site, redirecting the output to a file:
1. Run the following command on the **secondary** node, redirecting the output to a file:
```shell
git show-ref --head | grep -E "HEAD|(refs/(heads|tags|keep-around|merge-requests|environments|notes)/)" > secondary-site-refs
```
```shell
git show-ref --head | grep -E "HEAD|(refs/(heads|tags|keep-around|merge-requests|environments|notes)/)" > secondary-node-refs
```
1. Copy the files from the previous steps on the same system, and do a diff between the contents:
1. Copy the files from the previous steps on the same system, and do a diff between the contents:
```shell
diff primary-node-refs secondary-node-refs
```
```shell
diff primary-site-refs secondary-site-refs
```
## Current limitations
@ -191,10 +185,10 @@ progress to include them in [Geo: Verify all replicated data](https://gitlab.com
For now, you can verify their integrity
manually by following [these instructions](../../raketasks/check.md) on both
nodes, and comparing the output between them.
sites, and comparing the output between them.
In GitLab EE 12.1, Geo calculates checksums for attachments, LFS objects, and
archived traces on secondary nodes after the transfer, compares it with the
archived traces on secondary sites after the transfer, compares it with the
stored checksums, and rejects transfers if mismatched. Geo
currently does not support an automatic way to verify these data if they have
been synced before GitLab EE 12.1.

View File

@ -174,7 +174,7 @@ See [Review Apps](../review_apps.md) for more details about Review Apps.
To run tests in parallel on CI, the [Knapsack](https://github.com/KnapsackPro/knapsack)
gem is used. Knapsack reports are generated automatically and stored in the `GCS` bucket
`knapsack-reports` in the `gitlab-qa-resources` project. The [`KnapsackReport`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/tools/knapsack_report.rb)
`knapsack-reports` in the `gitlab-qa-resources` project. The [`KnapsackReport`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/qa/qa/support/knapsack_report.rb)
helper handles automated report generation and upload.
## Test metrics

View File

@ -56,9 +56,10 @@ usage data monthly.
To submit the data, [export your license usage](../../subscriptions/self_managed/index.md#export-your-license-usage)
and send it by email to the renewals service, `renewals-service@customers.gitlab.com`.
If you don't submit your data each month after your subscription start date, a banner displays to remind you to
submit your data. The banner displays in the **Admin Area** on the **Dashboard** and on the **Subscription**
pages. You can only dismiss it until the following month after you submit your license usage data.
If you don't submit your data each month after your subscription start date, an email is sent to the address
associated with your subscription and a banner displays to remind you to submit your data. The banner displays
in the **Admin Area** on the **Dashboard** and on the **Subscription** pages. You can only dismiss it until the
following month after you submit your license usage data.
## What happens when your license expires

View File

@ -7,9 +7,9 @@ module BulkImports
RETRIABLE_EXCEPTIONS = Gitlab::HTTP::HTTP_TIMEOUT_ERRORS
RETRIABLE_HTTP_CODES = [429].freeze
DEFAULT_RETRY_DELAY_SECONDS = 60
DEFAULT_RETRY_DELAY_SECONDS = 30
MAX_RETRIABLE_COUNT = 3
MAX_RETRIABLE_COUNT = 10
def initialize(message = nil, response: nil)
raise ArgumentError, 'message or response required' if message.blank? && response.blank?

View File

@ -56,12 +56,16 @@ module BulkImports
pipeline_step: step,
step_class: class_name
)
rescue BulkImports::NetworkError => e
if e.retriable?(context.tracker)
raise BulkImports::RetryPipelineError.new(e.message, e.retry_delay)
else
log_and_fail(e, step)
end
rescue BulkImports::RetryPipelineError
raise
rescue StandardError => e
log_import_failure(e, step)
mark_as_failed if abort_on_failure?
nil
log_and_fail(e, step)
end
def extracted_data_from
@ -74,11 +78,17 @@ module BulkImports
run if extracted_data.has_next_page?
end
def mark_as_failed
warn(message: 'Pipeline failed')
def log_and_fail(exception, step)
log_import_failure(exception, step)
context.entity.fail_op!
tracker.fail_op!
if abort_on_failure?
warn(message: 'Aborting entity migration due to pipeline failure')
context.entity.fail_op!
end
nil
end
def skip!(message, extra = {})

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module BulkImports
class RetryPipelineError < Error
attr_reader :retry_delay
def initialize(message, retry_delay)
super(message)
@retry_delay = retry_delay
end
end
end

View File

@ -76,8 +76,19 @@ module Gitlab
track_unique_action_by_user(MR_REOPEN_ACTION, user)
end
def track_approve_mr_action(user:)
def track_approve_mr_action(user:, merge_request:)
track_unique_action_by_user(MR_APPROVE_ACTION, user)
project = merge_request.target_project
return unless Feature.enabled?(:route_hll_to_snowplow_phase2, project.namespace)
Gitlab::Tracking.event(
'merge_requests',
MR_APPROVE_ACTION,
project: project,
namespace: project.namespace,
user: user
)
end
def track_unapprove_mr_action(user:)

View File

@ -938,9 +938,6 @@ msgstr ""
msgid "%{scope} results for term '%{term}'"
msgstr ""
msgid "%{search} %{description} %{scope}"
msgstr ""
msgid "%{seconds}s"
msgstr ""
@ -1355,6 +1352,11 @@ msgstr ""
msgid "0 bytes"
msgstr ""
msgid "1 Code quality finding"
msgid_plural "%d Code quality findings"
msgstr[0] ""
msgstr[1] ""
msgid "1 Day"
msgid_plural "%d Days"
msgstr[0] ""
@ -12988,9 +12990,6 @@ msgstr ""
msgid "Deprecations|The logs and tracing features were also deprecated in GitLab 14.7, and are %{removal_link_start} scheduled for removal %{link_end} in GitLab 15.0."
msgstr ""
msgid "Deprecations|The logs and tracing features were deprecated in GitLab 14.7 and are %{epicStart} scheduled for removal %{epicEnd} in GitLab 15.0."
msgstr ""
msgid "Deprecations|The metrics feature was deprecated in GitLab 14.7."
msgstr ""
@ -13080,9 +13079,6 @@ msgstr ""
msgid "DesignManagement|Comment"
msgstr ""
msgid "DesignManagement|Comments you resolve can be viewed and unresolved by going to the \"Resolved Comments\" section below"
msgstr ""
msgid "DesignManagement|Could not add a new comment. Please try again."
msgstr ""
@ -13125,9 +13121,6 @@ msgstr ""
msgid "DesignManagement|Keep comment"
msgstr ""
msgid "DesignManagement|Learn more about resolving comments"
msgstr ""
msgid "DesignManagement|Requested design version does not exist. Showing latest version instead"
msgstr ""
@ -14615,12 +14608,6 @@ msgstr ""
msgid "Environments|Commit"
msgstr ""
msgid "Environments|Currently showing %{fetched} results."
msgstr ""
msgid "Environments|Currently showing all results."
msgstr ""
msgid "Environments|Delete"
msgstr ""
@ -14657,9 +14644,6 @@ msgstr ""
msgid "Environments|How do I create an environment?"
msgstr ""
msgid "Environments|Install Elastic Stack on your cluster to enable advanced querying capabilities such as full text search."
msgstr ""
msgid "Environments|Job"
msgstr ""
@ -14669,9 +14653,6 @@ msgstr ""
msgid "Environments|Learn more about stopping environments"
msgstr ""
msgid "Environments|Logs from %{start} to %{end}."
msgstr ""
msgid "Environments|New environment"
msgstr ""
@ -14696,9 +14677,6 @@ msgstr ""
msgid "Environments|Open live environment"
msgstr ""
msgid "Environments|Pod name"
msgstr ""
msgid "Environments|Re-deploy environment"
msgstr ""
@ -14732,9 +14710,6 @@ msgstr ""
msgid "Environments|There are no deployments for this environment yet. %{linkStart}Learn more about setting up deployments.%{linkEnd}"
msgstr ""
msgid "Environments|There was an error fetching the logs. Please try again."
msgstr ""
msgid "Environments|This action will %{docsStart}retry the latest deployment%{docsEnd} with the commit %{commitId}, for this environment. Are you sure you want to continue?"
msgstr ""
@ -17690,9 +17665,15 @@ msgstr ""
msgid "Global notification settings"
msgstr ""
msgid "GlobalSearch| %{search} %{description} %{scope}"
msgstr ""
msgid "GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list."
msgstr ""
msgid "GlobalSearch|Groups"
msgstr ""
msgid "GlobalSearch|Issues I've created"
msgstr ""
@ -17708,6 +17689,9 @@ msgstr ""
msgid "GlobalSearch|Merge requests that I'm a reviewer"
msgstr ""
msgid "GlobalSearch|Projects"
msgstr ""
msgid "GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit."
msgstr ""
@ -17732,13 +17716,16 @@ msgstr ""
msgid "GlobalSearch|What are you searching for?"
msgstr ""
msgid "GlobalSearch|in all GitLab"
msgid "GlobalSearch|all GitLab"
msgstr ""
msgid "GlobalSearch|in group"
msgid "GlobalSearch|group"
msgstr ""
msgid "GlobalSearch|in project"
msgid "GlobalSearch|in %{scope}"
msgstr ""
msgid "GlobalSearch|project"
msgstr ""
msgid "Globally-allowed IP ranges"
@ -42433,9 +42420,6 @@ msgstr ""
msgid "VersionCheck|Your GitLab Version"
msgstr ""
msgid "View Documentation"
msgstr ""
msgid "View Stage: %{title}"
msgstr ""

View File

@ -6,6 +6,7 @@ RSpec.describe Projects::ProjectDeletedEvent do
where(:data, :valid) do
[
[{ project_id: 1, namespace_id: 2 }, true],
[{ project_id: 1, namespace_id: 2, root_namespace_id: 3 }, true],
[{ project_id: 1 }, false],
[{ namespace_id: 1 }, false],
[{ project_id: 'foo', namespace_id: 2 }, false],

View File

@ -153,6 +153,7 @@ RSpec.describe 'User uses header search field', :js do
it 'displays search options' do
fill_in_search('test')
expect(page).to have_selector(scoped_search_link('test', search_code: true))
expect(page).to have_selector(scoped_search_link('test', group_id: group.id, search_code: true))
expect(page).to have_selector(scoped_search_link('test', project_id: project.id, group_id: group.id, search_code: true))
@ -167,6 +168,7 @@ RSpec.describe 'User uses header search field', :js do
it 'displays search options' do
fill_in_search('test')
sleep 0.5
expect(page).to have_selector(scoped_search_link('test', search_code: true, repository_ref: 'master'))
expect(page).not_to have_selector(scoped_search_link('test', search_code: true, group_id: project.namespace_id, repository_ref: 'master'))
expect(page).to have_selector(scoped_search_link('test', search_code: true, project_id: project.id, repository_ref: 'master'))
@ -184,7 +186,7 @@ RSpec.describe 'User uses header search field', :js do
fill_in_search('Feature')
within(dashboard_search_options_popup_menu) do
expect(page).to have_text('"Feature" in all GitLab')
expect(page).to have_text('Feature in all GitLab')
expect(page).to have_no_text('Feature Flags')
end
end

View File

@ -1,7 +1,6 @@
import { GlCollapse, GlPopover } from '@gitlab/ui';
import { GlAccordionItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Cookies from '~/lib/utils/cookies';
import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue';
import DesignNoteSignedOut from '~/design_management/components/design_notes/design_note_signed_out.vue';
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
@ -27,8 +26,6 @@ const $route = {
},
};
const cookieKey = 'hide_design_resolved_comments_popover';
const mutate = jest.fn().mockResolvedValue();
describe('Design management design sidebar component', () => {
@ -40,9 +37,7 @@ describe('Design management design sidebar component', () => {
const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]');
const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]');
const findParticipants = () => wrapper.find(Participants);
const findCollapsible = () => wrapper.find(GlCollapse);
const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]');
const findPopover = () => wrapper.find(GlPopover);
const findResolvedCommentsToggle = () => wrapper.find(GlAccordionItem);
const findNewDiscussionDisclaimer = () =>
wrapper.find('[data-testid="new-discussion-disclaimer"]');
@ -61,7 +56,6 @@ describe('Design management design sidebar component', () => {
mutate,
},
},
stubs: { GlPopover },
provide: {
registerPath: '/users/sign_up?redirect_to_referer=yes',
signInPath: '/users/sign_in?redirect_to_referer=yes',
@ -119,7 +113,6 @@ describe('Design management design sidebar component', () => {
describe('when has discussions', () => {
beforeEach(() => {
Cookies.set(cookieKey, true);
createComponent();
});
@ -131,26 +124,23 @@ describe('Design management design sidebar component', () => {
expect(findResolvedDiscussions()).toHaveLength(1);
});
it('has resolved comments collapsible collapsed', () => {
expect(findCollapsible().attributes('visible')).toBeUndefined();
it('has resolved comments accordion item collapsed', () => {
expect(findResolvedCommentsToggle().props('visible')).toBe(false);
});
it('emits toggleResolveComments event on resolve comments button click', () => {
findToggleResolvedCommentsButton().vm.$emit('click');
it('emits toggleResolveComments event on resolve comments button click', async () => {
findResolvedCommentsToggle().vm.$emit('input', true);
await nextTick();
expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1);
});
it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', async () => {
expect(findCollapsible().attributes('visible')).toBeUndefined();
it('opens the accordion item when resolvedDiscussionsExpanded prop changes to true', async () => {
expect(findResolvedCommentsToggle().props('visible')).toBe(false);
wrapper.setProps({
resolvedDiscussionsExpanded: true,
});
await nextTick();
expect(findCollapsible().attributes('visible')).toBe('true');
});
it('does not popover about resolved comments', () => {
expect(findPopover().exists()).toBe(false);
expect(findResolvedCommentsToggle().props('visible')).toBe(true);
});
it('sends a mutation to set an active discussion when clicking on a discussion', () => {
@ -232,36 +222,6 @@ describe('Design management design sidebar component', () => {
});
});
describe('when showing resolved discussions for the first time', () => {
beforeEach(() => {
Cookies.set(cookieKey, false);
createComponent();
});
it('renders a popover if we show resolved comments collapsible for the first time', () => {
expect(findPopover().exists()).toBe(true);
});
it('scrolls to resolved threads link', () => {
expect(scrollIntoViewMock).toHaveBeenCalled();
});
it('dismisses a popover on the outside click', async () => {
wrapper.trigger('click');
await nextTick();
expect(findPopover().exists()).toBe(false);
});
it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => {
jest.spyOn(Cookies, 'set');
wrapper.trigger('click');
expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', {
expires: 365 * 10,
secure: false,
});
});
});
describe('when user is not logged in', () => {
const findDesignNoteSignedOut = () => wrapper.findComponent(DesignNoteSignedOut);
@ -292,7 +252,6 @@ describe('Design management design sidebar component', () => {
describe('design has discussions', () => {
beforeEach(() => {
Cookies.set(cookieKey, true);
createComponent();
});

View File

@ -88,57 +88,26 @@ exports[`Design management design index page renders design index 1`] = `
signinpath=""
/>
<gl-button-stub
buttontextclasses=""
category="primary"
class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4"
data-testid="resolved-comments"
icon="chevron-right"
id="resolved-comments"
size="medium"
variant="link"
<gl-accordion-stub
class="gl-mb-5"
headerlevel="3"
>
Resolved Comments (1)
</gl-button-stub>
<gl-popover-stub
container="popovercontainer"
cssclasses=""
placement="top"
show="true"
target="resolved-comments"
title="Resolved Comments"
>
<p>
Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below
</p>
<a
href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads"
rel="noopener noreferrer"
target="_blank"
<gl-accordion-item-stub
headerclass="gl-mb-5!"
title="Resolved Comments (1)"
>
Learn more about resolving comments
</a>
</gl-popover-stub>
<gl-collapse-stub
class="gl-mt-3"
>
<design-discussion-stub
data-testid="resolved-discussion"
designid="gid::/gitlab/Design/1"
discussion="[object Object]"
discussionwithopenform=""
markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
noteableid="gid::/gitlab/Design/1"
registerpath=""
signinpath=""
/>
</gl-collapse-stub>
<design-discussion-stub
data-testid="resolved-discussion"
designid="gid::/gitlab/Design/1"
discussion="[object Object]"
discussionwithopenform=""
markdownpreviewpath="/project-path/preview_markdown?target_type=Issue"
noteableid="gid::/gitlab/Design/1"
registerpath=""
signinpath=""
/>
</gl-accordion-item-stub>
</gl-accordion-stub>
</div>
</div>

View File

@ -0,0 +1,66 @@
import { GlIcon } from '@gitlab/ui';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue';
import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants';
import { multipleFindingsArr } from '../mock_data/diff_code_quality';
let wrapper;
const findIcon = () => wrapper.findComponent(GlIcon);
describe('DiffCodeQuality', () => {
afterEach(() => {
wrapper.destroy();
});
const createWrapper = (codeQuality, mountFunction = mountExtended) => {
return mountFunction(DiffCodeQuality, {
propsData: {
expandedLines: [],
line: 1,
codeQuality,
},
});
};
it('hides details and throws hideCodeQualityFindings event on close click', async () => {
wrapper = createWrapper(multipleFindingsArr);
expect(wrapper.findByTestId('diff-codequality').exists()).toBe(true);
await wrapper.findByTestId('diff-codequality-close').trigger('click');
expect(wrapper.emitted('hideCodeQualityFindings').length).toBe(1);
expect(wrapper.emitted().hideCodeQualityFindings[0][0]).toBe(wrapper.props('line'));
});
it('renders correct amount of list items for codequality array and their description', async () => {
wrapper = createWrapper(multipleFindingsArr);
const listItems = wrapper.findAll('li');
expect(wrapper.findAll('li').length).toBe(3);
listItems.wrappers.map((e, i) => {
return expect(e.text()).toEqual(multipleFindingsArr[i].description);
});
});
it.each`
severity
${'info'}
${'minor'}
${'major'}
${'critical'}
${'blocker'}
${'unknown'}
`('shows icon for $severity degradation', ({ severity }) => {
wrapper = createWrapper([{ severity }], shallowMountExtended);
expect(findIcon().exists()).toBe(true);
expect(findIcon().attributes()).toMatchObject({
class: `codequality-severity-icon ${SEVERITY_CLASSES[severity]}`,
name: SEVERITY_ICONS[severity],
size: '12',
});
});
});

View File

@ -1,7 +1,9 @@
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import DiffView from '~/diffs/components/diff_view.vue';
import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue';
import { diffCodeQuality } from '../mock_data/diff_code_quality';
describe('DiffView', () => {
const DiffExpansionCell = { template: `<div/>` };
@ -12,7 +14,7 @@ describe('DiffView', () => {
const setSelectedCommentPosition = jest.fn();
const getDiffRow = (wrapper) => wrapper.findComponent(DiffRow).vm;
const createWrapper = (props) => {
const createWrapper = (props, provide = {}) => {
Vue.use(Vuex);
const batchComments = {
@ -46,9 +48,33 @@ describe('DiffView', () => {
...props,
};
const stubs = { DiffExpansionCell, DiffRow, DiffCommentCell, DraftNote };
return shallowMount(DiffView, { propsData, store, stubs });
return shallowMount(DiffView, { propsData, store, stubs, provide });
};
it('does not render a codeQuality diff view when there is no finding', () => {
const wrapper = createWrapper();
expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(false);
});
it('does render a codeQuality diff view with the correct props when there is a finding & refactorCodeQualityInlineFindings flag is true ', async () => {
const wrapper = createWrapper(diffCodeQuality, {
glFeatures: { refactorCodeQualityInlineFindings: true },
});
wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2);
await nextTick();
expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(true);
expect(wrapper.findComponent(DiffCodeQuality).props().codeQuality.length).not.toBe(0);
});
it('does not render a codeQuality diff view when there is a finding & refactorCodeQualityInlineFindings flag is false ', async () => {
const wrapper = createWrapper(diffCodeQuality, {
glFeatures: { refactorCodeQualityInlineFindings: false },
});
wrapper.findComponent(DiffRow).vm.$emit('toggleCodeQualityFindings', 2);
await nextTick();
expect(wrapper.findComponent(DiffCodeQuality).exists()).toBe(false);
});
it.each`
type | side | container | sides | total
${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {}, renderDiscussion: true }, right: { lineDraft: {}, renderDiscussion: true } }} | ${2}

View File

@ -0,0 +1,62 @@
export const multipleFindingsArr = [
{
severity: 'minor',
description: 'Unexpected Debugger Statement.',
line: 2,
},
{
severity: 'major',
description:
'Function `aVeryLongFunction` has 52 lines of code (exceeds 25 allowed). Consider refactoring.',
line: 3,
},
{
severity: 'minor',
description: 'Arrow function has too many statements (52). Maximum allowed is 30.',
line: 3,
},
];
export const multipleFindings = {
filePath: 'index.js',
codequality: multipleFindingsArr,
};
export const singularFinding = {
filePath: 'index.js',
codequality: [multipleFindingsArr[0]],
};
export const diffCodeQuality = {
diffFile: { file_hash: '123' },
diffLines: [
{
left: {
type: 'old',
old_line: 1,
new_line: null,
codequality: [],
lineDraft: {},
},
},
{
left: {
type: null,
old_line: 2,
new_line: 1,
codequality: [],
lineDraft: {},
},
},
{
left: {
type: 'new',
old_line: null,
new_line: 2,
codequality: [multipleFindingsArr[0]],
lineDraft: {},
},
},
],
};

View File

@ -1,65 +1,50 @@
import Vue, { nextTick } from 'vue';
import groupFolderComponent from '~/groups/components/group_folder.vue';
import groupItemComponent from '~/groups/components/group_item.vue';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import GroupFolder from '~/groups/components/group_folder.vue';
import GroupItem from '~/groups/components/group_item.vue';
import { MAX_CHILDREN_COUNT } from '~/groups/constants';
import { mockGroups, mockParentGroupItem } from '../mock_data';
const createComponent = (groups = mockGroups, parentGroup = mockParentGroupItem) => {
const Component = Vue.extend(groupFolderComponent);
describe('GroupFolder component', () => {
let wrapper;
return new Component({
propsData: {
groups,
parentGroup,
},
});
};
Vue.component('GroupItem', GroupItem);
describe('GroupFolderComponent', () => {
let vm;
const findLink = () => wrapper.find('a');
beforeEach(async () => {
Vue.component('GroupItem', groupItemComponent);
vm = createComponent();
vm.$mount();
await nextTick();
});
const createComponent = ({ groups = mockGroups, parentGroup = mockParentGroupItem } = {}) =>
shallowMount(GroupFolder, {
propsData: {
groups,
parentGroup,
},
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('computed', () => {
describe('hasMoreChildren', () => {
it('should return false when childrenCount of group is less than MAX_CHILDREN_COUNT', () => {
expect(vm.hasMoreChildren).toBeFalsy();
});
});
it('does not render more children stats link when children count of group is under limit', () => {
wrapper = createComponent();
describe('moreChildrenStats', () => {
it('should return message with count of excess children over MAX_CHILDREN_COUNT limit', () => {
expect(vm.moreChildrenStats).toBe('3 more items');
});
});
expect(findLink().exists()).toBe(false);
});
describe('template', () => {
it('should render component template correctly', () => {
expect(vm.$el.classList.contains('group-list-tree')).toBeTruthy();
expect(vm.$el.querySelectorAll('li.group-row').length).toBe(7);
it('renders text of count of excess children when children count of group is over limit', () => {
const childrenCount = MAX_CHILDREN_COUNT + 1;
wrapper = createComponent({
parentGroup: {
...mockParentGroupItem,
childrenCount,
},
});
it('should render more children link when groups list has children over MAX_CHILDREN_COUNT limit', () => {
const parentGroup = { ...mockParentGroupItem };
parentGroup.childrenCount = 21;
expect(findLink().text()).toBe(`${childrenCount} more items`);
});
const newVm = createComponent(mockGroups, parentGroup);
newVm.$mount();
it('renders group items', () => {
wrapper = createComponent();
expect(newVm.$el.querySelector('li.group-row a.has-more-items')).toBeDefined();
newVm.$destroy();
});
expect(wrapper.findAllComponents(GroupItem)).toHaveLength(7);
});
});

View File

@ -5,26 +5,6 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
export const GROUP_VISIBILITY_TYPE = {
public: 'Public - The group and any public projects can be viewed without any authentication.',
internal:
'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
private: 'Private - The group and its projects can only be viewed by members.',
};
export const PROJECT_VISIBILITY_TYPE = {
public: 'Public - The project can be accessed without any authentication.',
internal: 'Internal - The project can be accessed by any logged in user except external users.',
private:
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
};
export const VISIBILITY_TYPE_ICON = {
public: 'earth',
internal: 'shield',
private: 'lock',
};
export const mockParentGroupItem = {
id: 55,
name: 'hardware',

View File

@ -1,22 +1,32 @@
import { GlSearchBoxByType } from '@gitlab/ui';
import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { s__, sprintf } from '~/locale';
import HeaderSearchApp from '~/header_search/components/app.vue';
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION } from '~/header_search/constants';
import {
SEARCH_INPUT_DESCRIPTION,
SEARCH_RESULTS_DESCRIPTION,
SEARCH_BOX_INDEX,
ICON_PROJECT,
ICON_GROUP,
ICON_SUBGROUP,
SCOPE_TOKEN_MAX_LENGTH,
} from '~/header_search/constants';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import { ENTER_KEY } from '~/lib/utils/keys';
import { visitUrl } from '~/lib/utils/url_utility';
import { truncate } from '~/lib/utils/text_utility';
import {
MOCK_SEARCH,
MOCK_SEARCH_QUERY,
MOCK_USERNAME,
MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS,
MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
MOCK_SEARCH_CONTEXT_FULL,
} from '../mock_data';
Vue.use(Vuex);
@ -52,11 +62,26 @@ describe('HeaderSearchApp', () => {
});
};
const formatScopeName = (scopeName) => {
if (!scopeName) {
return false;
}
const searchResultsScope = s__('GlobalSearch|in %{scope}');
return truncate(
sprintf(searchResultsScope, {
scope: scopeName,
}),
SCOPE_TOKEN_MAX_LENGTH,
);
};
afterEach(() => {
wrapper.destroy();
});
const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form');
const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType);
const findScopeToken = () => wrapper.findComponent(GlToken);
const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems);
const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
@ -106,53 +131,38 @@ describe('HeaderSearchApp', () => {
});
describe.each`
search | showDefault | showScoped | showAutocomplete | showDropdownNavigation
${null} | ${true} | ${false} | ${false} | ${true}
${''} | ${true} | ${false} | ${false} | ${true}
${'1'} | ${false} | ${false} | ${false} | ${false}
${')'} | ${false} | ${false} | ${false} | ${false}
${'t'} | ${false} | ${false} | ${true} | ${true}
${'te'} | ${false} | ${true} | ${true} | ${true}
${'tes'} | ${false} | ${true} | ${true} | ${true}
${MOCK_SEARCH} | ${false} | ${true} | ${true} | ${true}
`(
'Header Search Dropdown Items',
({ search, showDefault, showScoped, showAutocomplete, showDropdownNavigation }) => {
describe(`when search is ${search}`, () => {
beforeEach(() => {
window.gon.current_username = MOCK_USERNAME;
createComponent(
{ search },
{
autocompleteGroupedSearchOptions: () =>
search.match(/^[A-Za-z]+$/g) ? MOCK_SORTED_AUTOCOMPLETE_OPTIONS : [],
},
);
findHeaderSearchInput().vm.$emit('click');
});
it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
});
it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
});
it(`should${
showAutocomplete ? '' : ' not'
} render the Autocomplete Dropdown Items`, () => {
expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
});
it(`should${
showDropdownNavigation ? '' : ' not'
} render the Dropdown Navigation Component`, () => {
expect(findDropdownKeyboardNavigation().exists()).toBe(showDropdownNavigation);
});
search | showDefault | showScoped | showAutocomplete
${null} | ${true} | ${false} | ${false}
${''} | ${true} | ${false} | ${false}
${'t'} | ${false} | ${false} | ${true}
${'te'} | ${false} | ${false} | ${true}
${'tes'} | ${false} | ${true} | ${true}
${MOCK_SEARCH} | ${false} | ${true} | ${true}
`('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => {
describe(`when search is ${search}`, () => {
beforeEach(() => {
window.gon.current_username = MOCK_USERNAME;
createComponent({ search }, {});
findHeaderSearchInput().vm.$emit('click');
});
},
);
it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
});
it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
});
it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => {
expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
});
it(`should render the Dropdown Navigation Component`, () => {
expect(findDropdownKeyboardNavigation().exists()).toBe(true);
});
});
});
describe.each`
username | showDropdown | expectedDesc
@ -185,12 +195,18 @@ describe('HeaderSearchApp', () => {
`(
'Search Results Description',
({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => {
describe(`search is ${search}, loading is ${loading}, and showSearchDropdown is ${
Boolean(username) && showDropdown
}`, () => {
describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => {
beforeEach(() => {
window.gon.current_username = username;
createComponent({ search, loading }, { searchOptions: () => searchOptions });
createComponent(
{
search,
loading,
},
{
searchOptions: () => searchOptions,
},
);
findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : '');
});
@ -200,6 +216,121 @@ describe('HeaderSearchApp', () => {
});
},
);
describe('input box', () => {
describe.each`
search | searchOptions | hasToken
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true}
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true}
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true}
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true}
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true}
${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false}
${'x'} | ${[]} | ${false}
`('token', ({ search, searchOptions, hasToken }) => {
beforeEach(() => {
window.gon.current_username = MOCK_USERNAME;
createComponent(
{ search },
{
searchOptions: () => searchOptions,
},
);
});
it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${
searchOptions[0]?.html_id
}"`, () => {
expect(findScopeToken().exists()).toBe(hasToken);
});
it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${
searchOptions[0]?.scope || searchOptions[0]?.description
}"`, () => {
expect(findScopeToken().exists() && findScopeToken().text()).toBe(
formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description),
);
});
});
});
describe('form wrapper', () => {
describe.each`
searchContext | search | searchOptions
${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]}
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]}
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS}
${null} | ${null} | ${[]}
`('', ({ searchContext, search, searchOptions }) => {
beforeEach(() => {
window.gon.current_username = MOCK_USERNAME;
createComponent({ search, searchContext }, { searchOptions: () => searchOptions });
findHeaderSearchInput().vm.$emit('click');
});
const hasIcon = Boolean(searchContext?.group);
const isSearching = Boolean(search);
const isActive = Boolean(searchOptions.length > 0);
it(`${hasIcon ? 'with' : 'without'} search context classes contain "${
hasIcon ? 'has-icon' : 'has-no-icon'
}"`, () => {
const iconClassRegex = hasIcon ? 'has-icon' : 'has-no-icon';
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
});
it(`${isSearching ? 'with' : 'without'} search string classes contain "${
isSearching ? 'is-searching' : 'is-not-searching'
}"`, () => {
const iconClassRegex = isSearching ? 'is-searching' : 'is-not-searching';
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
});
it(`${isActive ? 'with' : 'without'} search results classes contain "${
isActive ? 'is-active' : 'is-not-active'
}"`, () => {
const iconClassRegex = isActive ? 'is-active' : 'is-not-active';
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
});
});
});
describe.each`
search | searchOptions | hasIcon | iconName
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT}
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP}
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP}
${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false}
`('token', ({ search, searchOptions, hasIcon, iconName }) => {
beforeEach(() => {
window.gon.current_username = MOCK_USERNAME;
createComponent(
{ search },
{
searchOptions: () => searchOptions,
},
);
});
it(`icon for data set type "${searchOptions[0]?.html_id}" ${
hasIcon ? 'is' : 'is NOT'
} rendered`, () => {
expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon);
});
it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${
searchOptions[0]?.html_id
}"`, () => {
expect(
findScopeToken().findComponent(GlIcon).exists() &&
findScopeToken().findComponent(GlIcon).attributes('name'),
).toBe(iconName);
});
});
});
describe('events', () => {
@ -285,18 +416,20 @@ describe('HeaderSearchApp', () => {
});
describe('computed', () => {
describe('currentFocusedOption', () => {
const MOCK_INDEX = 1;
describe.each`
MOCK_INDEX | search
${1} | ${null}
${SEARCH_BOX_INDEX} | ${'test'}
${2} | ${'test1'}
`('currentFocusedOption', ({ MOCK_INDEX, search }) => {
beforeEach(() => {
createComponent();
createComponent({ search });
window.gon.current_username = MOCK_USERNAME;
findHeaderSearchInput().vm.$emit('click');
});
it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, async () => {
it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => {
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
await nextTick();
expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]);
});
});
@ -308,15 +441,25 @@ describe('HeaderSearchApp', () => {
createComponent();
});
it('onKey-enter submits a search', async () => {
it('onKey-enter submits a search', () => {
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
await nextTick();
expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
});
});
describe('with less than min characters and no dropdown results', () => {
beforeEach(() => {
createComponent({ search: 'x' });
});
it('onKey-enter will NOT submit a search', () => {
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
});
});
describe('with currentFocusedOption', () => {
const MOCK_INDEX = 1;
@ -326,9 +469,9 @@ describe('HeaderSearchApp', () => {
findHeaderSearchInput().vm.$emit('click');
});
it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => {
it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => {
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
await nextTick();
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url);
});

View File

@ -1,9 +1,11 @@
import { GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { truncate } from '~/lib/utils/text_utility';
import { MSG_IN_ALL_GITLAB, SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants';
import {
MOCK_SEARCH,
MOCK_SCOPED_SEARCH_OPTIONS,
@ -41,9 +43,12 @@ describe('HeaderSearchScopedItems', () => {
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findGlDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
const findScopeTokens = () => wrapper.findAllComponents(GlToken);
const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text()));
const findScopeTokensIcons = () =>
findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon));
const findDropdownItemAriaLabels = () =>
findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label')));
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
@ -59,15 +64,31 @@ describe('HeaderSearchScopedItems', () => {
});
it('renders titles correctly', () => {
findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH));
});
it('renders scope names correctly', () => {
const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
trimText(`"${MOCK_SEARCH}" ${o.description} ${o.scope || ''}`),
truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH),
);
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
expect(findScopeTokensText()).toStrictEqual(expectedTitles);
});
it('renders scope icons correctly', () => {
findScopeTokensIcons().forEach((icon, i) => {
const w = icon.wrappers[0];
expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon);
});
});
it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => {
expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false);
});
it('renders aria-labels correctly', () => {
const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) =>
trimText(`${MOCK_SEARCH} ${o.description} ${o.scope || ''}`),
trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`),
);
expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels);
});
@ -98,21 +119,5 @@ describe('HeaderSearchScopedItems', () => {
});
});
});
describe.each`
autosuggestResults | showDivider
${[]} | ${false}
${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${true}
`('scoped search items', ({ autosuggestResults, showDivider }) => {
describe(`when when we have ${autosuggestResults.length} auto-sugest results`, () => {
beforeEach(() => {
createComponent({}, { autocompleteGroupedSearchOptions: () => autosuggestResults }, {});
});
it(`divider should${showDivider ? '' : ' not'} be shown`, () => {
expect(findGlDropdownDivider().exists()).toBe(showDivider);
});
});
});
});
});

View File

@ -4,9 +4,12 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
MSG_IN_PROJECT,
MSG_IN_GROUP,
MSG_IN_ALL_GITLAB,
PROJECTS_CATEGORY,
ICON_PROJECT,
GROUPS_CATEGORY,
ICON_GROUP,
ICON_SUBGROUP,
} from '~/header_search/constants';
export const MOCK_USERNAME = 'anyone';
@ -27,12 +30,24 @@ export const MOCK_PROJECT = {
path: '/mock-project',
};
export const MOCK_PROJECT_LONG = {
id: 124,
name: 'Mock Project Name That Is Ridiculously Long And It Goes Forever',
path: '/mock-project-name-that-is-ridiculously-long-and-it-goes-forever',
};
export const MOCK_GROUP = {
id: 321,
name: 'MockGroup',
path: '/mock-group',
};
export const MOCK_SUBGROUP = {
id: 322,
name: 'MockSubGroup',
path: `${MOCK_GROUP}/mock-subgroup`,
};
export const MOCK_SEARCH_QUERY = 'http://gitlab.com/search?search=test';
export const MOCK_SEARCH = 'test';
@ -44,6 +59,20 @@ export const MOCK_SEARCH_CONTEXT = {
group_metadata: {},
};
export const MOCK_SEARCH_CONTEXT_FULL = {
group: {
id: 31,
name: 'testGroup',
full_name: 'testGroup',
},
group_metadata: {
group_path: 'testGroup',
name: 'testGroup',
issues_path: '/groups/testGroup/-/issues',
mr_path: '/groups/testGroup/-/merge_requests',
},
};
export const MOCK_DEFAULT_SEARCH_OPTIONS = [
{
html_id: 'default-issues-assigned',
@ -76,13 +105,51 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [
{
html_id: 'scoped-in-project',
scope: MOCK_PROJECT.name,
description: MSG_IN_PROJECT,
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
url: MOCK_PROJECT.path,
},
{
html_id: 'scoped-in-project-long',
scope: MOCK_PROJECT_LONG.name,
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
url: MOCK_PROJECT_LONG.path,
},
{
html_id: 'scoped-in-group',
scope: MOCK_GROUP.name,
scopeCategory: GROUPS_CATEGORY,
icon: ICON_GROUP,
url: MOCK_GROUP.path,
},
{
html_id: 'scoped-in-subgroup',
scope: MOCK_SUBGROUP.name,
scopeCategory: GROUPS_CATEGORY,
icon: ICON_SUBGROUP,
url: MOCK_SUBGROUP.path,
},
{
html_id: 'scoped-in-all',
description: MSG_IN_ALL_GITLAB,
url: MOCK_ALL_PATH,
},
];
export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [
{
html_id: 'scoped-in-project',
scope: MOCK_PROJECT.name,
scopeCategory: PROJECTS_CATEGORY,
icon: ICON_PROJECT,
url: MOCK_PROJECT.path,
},
{
html_id: 'scoped-in-group',
scope: MOCK_GROUP.name,
description: MSG_IN_GROUP,
scopeCategory: GROUPS_CATEGORY,
icon: ICON_GROUP,
url: MOCK_GROUP.path,
},
{

View File

@ -9,6 +9,7 @@ import {
MOCK_SEARCH_CONTEXT,
MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS_DEF,
MOCK_PROJECT,
MOCK_GROUP,
MOCK_ALL_PATH,
@ -284,7 +285,7 @@ describe('Header Search Store Getters', () => {
it('returns the correct array', () => {
expect(getters.scopedSearchOptions(state, mockGetters)).toStrictEqual(
MOCK_SCOPED_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS_DEF,
);
});
});
@ -308,6 +309,11 @@ describe('Header Search Store Getters', () => {
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS}
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
${1} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
${'('} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${[]} | ${[]}
${'t'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
${'te'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
${'tes'} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
`(
'searchOptions',
({

View File

@ -1,29 +1,21 @@
import Vue from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import emptyState from '~/ide/components/commit_sidebar/empty_state.vue';
import { shallowMount } from '@vue/test-utils';
import EmptyState from '~/ide/components/commit_sidebar/empty_state.vue';
import { createStore } from '~/ide/stores';
describe('IDE commit panel empty state', () => {
let vm;
let store;
describe('IDE commit panel EmptyState component', () => {
let wrapper;
beforeEach(() => {
store = createStore();
const Component = Vue.extend(emptyState);
Vue.set(store.state, 'noChangesStateSvgPath', 'no-changes');
vm = createComponentWithStore(Component, store);
vm.$mount();
const store = createStore();
store.state.noChangesStateSvgPath = 'no-changes';
wrapper = shallowMount(EmptyState, { store });
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
it('renders no changes text when last commit message is empty', () => {
expect(vm.$el.textContent).toContain('No changes');
expect(wrapper.find('h4').text()).toBe('No changes');
});
});

View File

@ -1,51 +1,47 @@
import Vue, { nextTick } from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import commitSidebarList from '~/ide/components/commit_sidebar/list.vue';
import { createStore } from '~/ide/stores';
import { shallowMount } from '@vue/test-utils';
import CommitSidebarList from '~/ide/components/commit_sidebar/list.vue';
import ListItem from '~/ide/components/commit_sidebar/list_item.vue';
import { file } from '../../helpers';
describe('Multi-file editor commit sidebar list', () => {
let store;
let vm;
let wrapper;
beforeEach(() => {
store = createStore();
const Component = Vue.extend(commitSidebarList);
vm = createComponentWithStore(Component, store, {
title: 'Staged',
fileList: [],
action: 'stageAllChanges',
actionBtnText: 'stage all',
actionBtnIcon: 'history',
activeFileKey: 'staged-testing',
keyPrefix: 'staged',
const mountComponent = ({ fileList }) =>
shallowMount(CommitSidebarList, {
propsData: {
title: 'Staged',
fileList,
action: 'stageAllChanges',
actionBtnText: 'stage all',
actionBtnIcon: 'history',
activeFileKey: 'staged-testing',
keyPrefix: 'staged',
},
});
vm.$mount();
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('with a list of files', () => {
beforeEach(async () => {
const f = file('file name');
f.changed = true;
vm.fileList.push(f);
await nextTick();
wrapper = mountComponent({ fileList: [f] });
});
it('renders list', () => {
expect(vm.$el.querySelectorAll('.multi-file-commit-list > li').length).toBe(1);
expect(wrapper.findAllComponents(ListItem)).toHaveLength(1);
});
});
describe('empty files array', () => {
it('renders no changes text when empty', () => {
expect(vm.$el.textContent).toContain('No changes');
describe('with empty files array', () => {
beforeEach(() => {
wrapper = mountComponent({ fileList: [] });
});
it('renders no changes text ', () => {
expect(wrapper.text()).toContain('No changes');
});
});
});

View File

@ -1,32 +1,22 @@
import Vue, { nextTick } from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import successMessage from '~/ide/components/commit_sidebar/success_message.vue';
import { shallowMount } from '@vue/test-utils';
import SuccessMessage from '~/ide/components/commit_sidebar/success_message.vue';
import { createStore } from '~/ide/stores';
describe('IDE commit panel successful commit state', () => {
let vm;
let store;
let wrapper;
beforeEach(() => {
store = createStore();
const Component = Vue.extend(successMessage);
vm = createComponentWithStore(Component, store, {
committedStateSvgPath: 'committed-state',
});
vm.$mount();
const store = createStore();
store.state.committedStateSvgPath = 'committed-state';
store.state.lastCommitMsg = 'testing commit message';
wrapper = shallowMount(SuccessMessage, { store });
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
it('renders last commit message when it exists', async () => {
vm.$store.state.lastCommitMsg = 'testing commit message';
await nextTick();
expect(vm.$el.textContent).toContain('testing commit message');
it('renders last commit message when it exists', () => {
expect(wrapper.text()).toContain('testing commit message');
});
});

View File

@ -1,82 +1,72 @@
import Vue, { nextTick } from 'vue';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import { GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import IdeTreeList from '~/ide/components/ide_tree_list.vue';
import { createStore } from '~/ide/stores';
import FileTree from '~/vue_shared/components/file_tree.vue';
import { file } from '../helpers';
import { projectData } from '../mock_data';
describe('IDE tree list', () => {
const Component = Vue.extend(IdeTreeList);
const normalBranchTree = [file('fileName')];
const emptyBranchTree = [];
let vm;
let store;
describe('IdeTreeList component', () => {
let wrapper;
const bootstrapWithTree = (tree = normalBranchTree) => {
const mountComponent = ({ tree, loading = false } = {}) => {
const store = createStore();
store.state.currentProjectId = 'abcproject';
store.state.currentBranchId = 'main';
store.state.projects.abcproject = { ...projectData };
Vue.set(store.state.trees, 'abcproject/main', {
tree,
loading: false,
});
Vue.set(store.state.trees, 'abcproject/main', { tree, loading });
vm = createComponentWithStore(Component, store, {
viewerType: 'edit',
wrapper = shallowMount(IdeTreeList, {
propsData: {
viewerType: 'edit',
},
store,
});
};
beforeEach(() => {
store = createStore();
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
describe('normal branch', () => {
beforeEach(() => {
bootstrapWithTree();
jest.spyOn(vm, '$emit').mockImplementation(() => {});
vm.$mount();
});
const tree = [file('fileName')];
it('emits tree-ready event', () => {
expect(vm.$emit).toHaveBeenCalledTimes(1);
expect(vm.$emit).toHaveBeenCalledWith('tree-ready');
mountComponent({ tree });
expect(wrapper.emitted('tree-ready')).toEqual([[]]);
});
it('renders loading indicator', async () => {
store.state.trees['abcproject/main'].loading = true;
it('renders loading indicator', () => {
mountComponent({ tree, loading: true });
await nextTick();
expect(vm.$el.querySelector('.multi-file-loading-container')).not.toBeNull();
expect(vm.$el.querySelectorAll('.multi-file-loading-container').length).toBe(3);
expect(wrapper.findAllComponents(GlSkeletonLoader)).toHaveLength(3);
});
it('renders list of files', () => {
expect(vm.$el.textContent).toContain('fileName');
mountComponent({ tree });
expect(wrapper.findAllComponents(FileTree)).toHaveLength(1);
expect(wrapper.findComponent(FileTree).props('file')).toEqual(tree[0]);
});
});
describe('empty-branch state', () => {
beforeEach(() => {
bootstrapWithTree(emptyBranchTree);
jest.spyOn(vm, '$emit').mockImplementation(() => {});
vm.$mount();
mountComponent({ tree: [] });
});
it('still emits tree-ready event', () => {
expect(vm.$emit).toHaveBeenCalledWith('tree-ready');
it('emits tree-ready event', () => {
expect(wrapper.emitted('tree-ready')).toEqual([[]]);
});
it('does not load files if the branch is empty', () => {
expect(vm.$el.textContent).not.toContain('fileName');
expect(vm.$el.textContent).toContain('No files');
it('does not render files', () => {
expect(wrapper.findAllComponents(FileTree)).toHaveLength(0);
});
it('renders empty state text', () => {
expect(wrapper.text()).toBe('No files');
});
});
});

View File

@ -1,3 +1,4 @@
import { GlTab } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { editor as monacoEditor, Range } from 'monaco-editor';
import Vue, { nextTick } from 'vue';
@ -125,7 +126,7 @@ describe('RepoEditor', () => {
};
const findEditor = () => wrapper.find('[data-testid="editor-container"]');
const findTabs = () => wrapper.findAll('.ide-mode-tabs .nav-links li');
const findTabs = () => wrapper.findAllComponents(GlTab);
const findPreviewTab = () => wrapper.find('[data-testid="preview-tab"]');
beforeEach(() => {
@ -201,12 +202,12 @@ describe('RepoEditor', () => {
const tabs = findTabs();
expect(tabs).toHaveLength(2);
expect(tabs.at(0).text()).toBe('Edit');
expect(tabs.at(1).text()).toBe('Preview Markdown');
expect(tabs.at(0).element.dataset.testid).toBe('edit-tab');
expect(tabs.at(1).element.dataset.testid).toBe('preview-tab');
});
it('renders markdown for tempFile', async () => {
findPreviewTab().trigger('click');
findPreviewTab().vm.$emit('click');
await waitForPromises();
expect(wrapper.find(ContentViewer).html()).toContain(dummyFile.text.content);
});

View File

@ -29,6 +29,7 @@ import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_ro
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
import IssuesListApp from '~/issues/list/components/issues_list_app.vue';
import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue';
import {
CREATED_DESC,
RELATIVE_POSITION,
@ -98,6 +99,7 @@ describe('CE IssuesListApp component', () => {
};
let defaultQueryResponse = getIssuesQueryResponse;
let router;
if (IS_EE) {
defaultQueryResponse = cloneDeep(getIssuesQueryResponse);
defaultQueryResponse.data.project.issues.nodes[0].blockingCount = 1;
@ -133,9 +135,11 @@ describe('CE IssuesListApp component', () => {
[setSortPreferenceMutation, sortPreferenceMutationResponse],
];
router = new VueRouter({ mode: 'history' });
return mountFn(IssuesListApp, {
apolloProvider: createMockApollo(requestHandlers),
router: new VueRouter({ mode: 'history' }),
router,
provide: {
...defaultProvide,
...provide,
@ -736,7 +740,7 @@ describe('CE IssuesListApp component', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.spyOn(wrapper.vm.$router, 'push');
router.push = jest.fn();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
@ -746,16 +750,26 @@ describe('CE IssuesListApp component', () => {
});
it('updates url to the new tab', () => {
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ state: IssuableStates.Closed }),
});
});
});
describe.each`
event | params
${'next-page'} | ${{ page_after: 'endCursor', page_before: undefined, first_page_size: 20, last_page_size: undefined }}
${'previous-page'} | ${{ page_after: undefined, page_before: 'startCursor', first_page_size: undefined, last_page_size: 20 }}
event | params
${'next-page'} | ${{
page_after: 'endCursor',
page_before: undefined,
first_page_size: 20,
last_page_size: undefined,
}}
${'previous-page'} | ${{
page_after: undefined,
page_before: 'startCursor',
first_page_size: undefined,
last_page_size: 20,
}}
`('when "$event" event is emitted by IssuableList', ({ event, params }) => {
beforeEach(() => {
wrapper = mountComponent({
@ -766,7 +780,7 @@ describe('CE IssuesListApp component', () => {
},
},
});
jest.spyOn(wrapper.vm.$router, 'push');
router.push = jest.fn();
findIssuableList().vm.$emit(event);
});
@ -776,7 +790,7 @@ describe('CE IssuesListApp component', () => {
});
it(`updates url`, () => {
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining(params),
});
});
@ -888,13 +902,13 @@ describe('CE IssuesListApp component', () => {
'updates to the new sort when payload is `%s`',
async (sortKey) => {
wrapper = mountComponent();
jest.spyOn(wrapper.vm.$router, 'push');
router.push = jest.fn();
findIssuableList().vm.$emit('sort', sortKey);
jest.runOnlyPendingTimers();
await nextTick();
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ sort: urlSortParams[sortKey] }),
});
},
@ -907,13 +921,13 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
provide: { initialSort, isIssueRepositioningDisabled: true },
});
jest.spyOn(wrapper.vm.$router, 'push');
router.push = jest.fn();
findIssuableList().vm.$emit('sort', RELATIVE_POSITION_ASC);
});
it('does not update the sort to manual', () => {
expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
expect(router.push).not.toHaveBeenCalled();
});
it('shows an alert to tell the user that manual reordering is disabled', () => {
@ -978,12 +992,12 @@ describe('CE IssuesListApp component', () => {
describe('when "filter" event is emitted by IssuableList', () => {
it('updates IssuableList with url params', async () => {
wrapper = mountComponent();
jest.spyOn(wrapper.vm.$router, 'push');
router.push = jest.fn();
findIssuableList().vm.$emit('filter', filteredTokens);
await nextTick();
expect(wrapper.vm.$router.push).toHaveBeenCalledWith({
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining(urlParams),
});
});
@ -993,13 +1007,13 @@ describe('CE IssuesListApp component', () => {
wrapper = mountComponent({
provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
});
jest.spyOn(wrapper.vm.$router, 'push');
router.push = jest.fn();
findIssuableList().vm.$emit('filter', filteredTokens);
});
it('does not update url params', () => {
expect(wrapper.vm.$router.push).not.toHaveBeenCalled();
expect(router.push).not.toHaveBeenCalled();
});
it('shows an alert to tell the user they must be signed in to search', () => {
@ -1030,4 +1044,19 @@ describe('CE IssuesListApp component', () => {
expect(mockQuery).toHaveBeenCalledWith(expect.objectContaining({ hideUsers }));
});
});
describe('when "page-size-change" event is emitted by IssuableList', () => {
it('updates url params with new page size', async () => {
wrapper = mountComponent();
router.push = jest.fn();
findIssuableList().vm.$emit('page-size-change', 50);
await nextTick();
expect(router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith({
query: expect.objectContaining({ first_page_size: 50 }),
});
});
});
});

View File

@ -10,12 +10,7 @@ import {
urlParams,
urlParamsWithSpecialValues,
} from 'jest/issues/list/mock_data';
import {
PAGE_SIZE,
PAGE_SIZE_MANUAL,
RELATIVE_POSITION_ASC,
urlSortParams,
} from '~/issues/list/constants';
import { PAGE_SIZE, urlSortParams } from '~/issues/list/constants';
import {
convertToApiParams,
convertToSearchQuery,
@ -29,52 +24,30 @@ import {
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
describe('getInitialPageParams', () => {
it.each(Object.keys(urlSortParams))(
'returns the correct page params for sort key %s',
(sortKey) => {
const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
it('returns page params with a default page size when no arguments are given', () => {
expect(getInitialPageParams()).toEqual({ firstPageSize: PAGE_SIZE });
});
expect(getInitialPageParams(sortKey)).toEqual({ firstPageSize });
},
);
it('returns page params with the given page size', () => {
const pageSize = 100;
expect(getInitialPageParams(pageSize)).toEqual({ firstPageSize: pageSize });
});
it.each(Object.keys(urlSortParams))(
'returns the correct page params for sort key %s with afterCursor',
(sortKey) => {
const firstPageSize = sortKey === RELATIVE_POSITION_ASC ? PAGE_SIZE_MANUAL : PAGE_SIZE;
const lastPageSize = undefined;
const afterCursor = 'randomCursorString';
const beforeCursor = undefined;
const pageParams = getInitialPageParams(
sortKey,
firstPageSize,
lastPageSize,
afterCursor,
beforeCursor,
);
it('does not return firstPageSize when lastPageSize is provided', () => {
const firstPageSize = 100;
const lastPageSize = 50;
const afterCursor = undefined;
const beforeCursor = 'randomCursorString';
const pageParams = getInitialPageParams(
100,
firstPageSize,
lastPageSize,
afterCursor,
beforeCursor,
);
expect(pageParams).toEqual({ firstPageSize, afterCursor });
},
);
it.each(Object.keys(urlSortParams))(
'returns the correct page params for sort key %s with beforeCursor',
(sortKey) => {
const firstPageSize = undefined;
const lastPageSize = PAGE_SIZE;
const afterCursor = undefined;
const beforeCursor = 'anotherRandomCursorString';
const pageParams = getInitialPageParams(
sortKey,
firstPageSize,
lastPageSize,
afterCursor,
beforeCursor,
);
expect(pageParams).toEqual({ lastPageSize, beforeCursor });
},
);
expect(pageParams).toEqual({ lastPageSize, beforeCursor });
});
});
describe('getSortKey', () => {

View File

@ -1,49 +1,50 @@
import Vue from 'vue';
import edited from '~/issues/show/components/edited.vue';
import { shallowMount } from '@vue/test-utils';
import Edited from '~/issues/show/components/edited.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
}
describe('Edited component', () => {
let wrapper;
describe('edited', () => {
const EditedComponent = Vue.extend(edited);
const findAuthorLink = () => wrapper.find('a');
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
const formatText = (text) => text.trim().replace(/\s\s+/g, ' ');
it('should render an edited at+by string', () => {
const editedComponent = new EditedComponent({
propsData: {
updatedAt: '2017-05-15T12:31:04.428Z',
updatedByName: 'Some User',
updatedByPath: '/some_user',
},
}).$mount();
const mountComponent = (propsData) => shallowMount(Edited, { propsData });
expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited[\s\S]+?by Some User/);
expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/);
expect(editedComponent.$el.querySelector('time')).toBeTruthy();
afterEach(() => {
wrapper.destroy();
});
it('renders an edited at+by string', () => {
wrapper = mountComponent({
updatedAt: '2017-05-15T12:31:04.428Z',
updatedByName: 'Some User',
updatedByPath: '/some_user',
});
expect(formatText(wrapper.text())).toBe('Edited by Some User');
expect(findAuthorLink().attributes('href')).toBe('/some_user');
expect(findTimeAgoTooltip().exists()).toBe(true);
});
it('if no updatedAt is provided, no time element will be rendered', () => {
const editedComponent = new EditedComponent({
propsData: {
updatedByName: 'Some User',
updatedByPath: '/some_user',
},
}).$mount();
wrapper = mountComponent({
updatedByName: 'Some User',
updatedByPath: '/some_user',
});
expect(formatText(editedComponent.$el.innerText)).toMatch(/Edited by Some User/);
expect(editedComponent.$el.querySelector('.author-link').href).toMatch(/\/some_user$/);
expect(editedComponent.$el.querySelector('time')).toBeFalsy();
expect(formatText(wrapper.text())).toBe('Edited by Some User');
expect(findAuthorLink().attributes('href')).toBe('/some_user');
expect(findTimeAgoTooltip().exists()).toBe(false);
});
it('if no updatedByName and updatedByPath is provided, no user element will be rendered', () => {
const editedComponent = new EditedComponent({
propsData: {
updatedAt: '2017-05-15T12:31:04.428Z',
},
}).$mount();
wrapper = mountComponent({
updatedAt: '2017-05-15T12:31:04.428Z',
});
expect(formatText(editedComponent.$el.innerText)).not.toMatch(/by Some User/);
expect(editedComponent.$el.querySelector('.author-link')).toBeFalsy();
expect(editedComponent.$el.querySelector('time')).toBeTruthy();
expect(formatText(wrapper.text())).toBe('Edited');
expect(findAuthorLink().exists()).toBe(false);
expect(findTimeAgoTooltip().exists()).toBe(true);
});
});

View File

@ -1,370 +0,0 @@
import { GlSprintf, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { scrollDown } from '~/lib/utils/scroll_utils';
import EnvironmentLogs from '~/logs/components/environment_logs.vue';
import { createStore } from '~/logs/stores';
import {
mockEnvName,
mockEnvironments,
mockPods,
mockLogsResult,
mockTrace,
mockEnvironmentsEndpoint,
mockDocumentationPath,
} from '../mock_data';
jest.mock('~/lib/utils/scroll_utils');
const module = 'environmentLogs';
jest.mock('lodash/throttle', () =>
jest.fn((func) => {
return func;
}),
);
describe('EnvironmentLogs', () => {
let store;
let dispatch;
let wrapper;
let state;
const propsData = {
environmentName: mockEnvName,
environmentsPath: mockEnvironmentsEndpoint,
clusterApplicationsDocumentationPath: mockDocumentationPath,
clustersPath: '/gitlab-org',
};
const updateControlBtnsMock = jest.fn();
const LogControlButtonsStub = {
template: '<div/>',
methods: {
update: updateControlBtnsMock,
},
props: {
scrollDownButtonDisabled: false,
},
};
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
const findSimpleFilters = () => wrapper.find({ ref: 'log-simple-filters' });
const findAdvancedFilters = () => wrapper.find({ ref: 'log-advanced-filters' });
const findElasticsearchNotice = () => wrapper.find({ ref: 'elasticsearchNotice' });
const findLogControlButtons = () => wrapper.find(LogControlButtonsStub);
const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' });
const findLogTrace = () => wrapper.find({ ref: 'logTrace' });
const findLogFooter = () => wrapper.find({ ref: 'logFooter' });
const getInfiniteScrollAttr = (attr) => parseInt(findInfiniteScroll().attributes(attr), 10);
const mockSetInitData = () => {
state.pods.options = mockPods;
state.environments.current = mockEnvName;
[state.pods.current] = state.pods.options;
state.logs.lines = [];
};
const mockShowPodLogs = () => {
state.pods.options = mockPods;
[state.pods.current] = mockPods;
state.logs.lines = mockLogsResult;
};
const mockFetchEnvs = () => {
state.environments.options = mockEnvironments;
};
const initWrapper = () => {
wrapper = shallowMount(EnvironmentLogs, {
propsData,
store,
stubs: {
LogControlButtons: LogControlButtonsStub,
GlInfiniteScroll: {
name: 'gl-infinite-scroll',
template: `
<div>
<slot name="header"></slot>
<slot name="items"></slot>
<slot></slot>
</div>
`,
},
GlSprintf,
},
});
};
beforeEach(() => {
store = createStore();
state = store.state.environmentLogs;
jest.spyOn(store, 'dispatch').mockResolvedValue();
dispatch = store.dispatch;
});
afterEach(() => {
store.dispatch.mockReset();
if (wrapper) {
wrapper.destroy();
}
});
it('displays UI elements', () => {
initWrapper();
expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true);
expect(findSimpleFilters().exists()).toBe(true);
expect(findLogControlButtons().exists()).toBe(true);
expect(findInfiniteScroll().exists()).toBe(true);
expect(findLogTrace().exists()).toBe(true);
});
it('mounted inits data', () => {
initWrapper();
expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, {
timeRange: expect.objectContaining({
default: true,
}),
environmentName: mockEnvName,
podName: null,
});
expect(dispatch).toHaveBeenCalledWith(`${module}/fetchEnvironments`, mockEnvironmentsEndpoint);
});
describe('loading state', () => {
beforeEach(() => {
state.pods.options = [];
state.logs.lines = [];
state.logs.isLoading = true;
state.environments = {
options: [],
isLoading: true,
};
initWrapper();
});
it('does not display an alert to upgrade to ES', () => {
expect(findElasticsearchNotice().exists()).toBe(false);
});
it('displays a disabled environments dropdown', () => {
expect(findEnvironmentsDropdown().attributes('disabled')).toBe('true');
expect(findEnvironmentsDropdown().findAll(GlDropdownItem).length).toBe(0);
});
it('does not update buttons state', () => {
expect(updateControlBtnsMock).not.toHaveBeenCalled();
});
it('shows an infinite scroll with no content', () => {
expect(getInfiniteScrollAttr('fetched-items')).toBe(0);
});
it('shows an infinite scroll container with no set max-height ', () => {
expect(findInfiniteScroll().attributes('max-list-height')).toBeUndefined();
});
it('shows a logs trace', () => {
expect(findLogTrace().text()).toBe('');
expect(findLogTrace().find('.js-build-loader-animation').isVisible()).toBe(true);
});
});
describe('k8s environment', () => {
beforeEach(() => {
state.pods.options = [];
state.logs.lines = [];
state.logs.isLoading = false;
state.environments = {
options: mockEnvironments,
current: 'staging',
isLoading: false,
};
initWrapper();
});
it('displays an alert to upgrade to ES', () => {
expect(findElasticsearchNotice().exists()).toBe(true);
});
it('displays simple filters for kubernetes logs API', () => {
expect(findSimpleFilters().exists()).toBe(true);
expect(findAdvancedFilters().exists()).toBe(false);
});
});
describe('state with data', () => {
beforeEach(() => {
dispatch.mockImplementation((actionName) => {
if (actionName === `${module}/setInitData`) {
mockSetInitData();
} else if (actionName === `${module}/showPodLogs`) {
mockShowPodLogs();
} else if (actionName === `${module}/fetchEnvironments`) {
mockFetchEnvs();
mockShowPodLogs();
}
});
initWrapper();
});
afterEach(() => {
scrollDown.mockReset();
updateControlBtnsMock.mockReset();
});
it('does not display an alert to upgrade to ES', () => {
expect(findElasticsearchNotice().exists()).toBe(false);
});
it('populates environments dropdown', () => {
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
expect(findEnvironmentsDropdown().props('text')).toBe(mockEnvName);
expect(items.length).toBe(mockEnvironments.length);
mockEnvironments.forEach((env, i) => {
const item = items.at(i);
expect(item.text()).toBe(env.name);
});
});
it('dropdown has one environment selected', () => {
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
mockEnvironments.forEach((env, i) => {
const item = items.at(i);
if (item.text() !== mockEnvName) {
expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy();
} else {
expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy();
}
});
});
it('displays advanced filters for elasticsearch logs API', () => {
expect(findSimpleFilters().exists()).toBe(false);
expect(findAdvancedFilters().exists()).toBe(true);
});
it('shows infinite scroll with content', () => {
expect(getInfiniteScrollAttr('fetched-items')).toBe(mockTrace.length);
});
it('populates logs trace', () => {
const trace = findLogTrace();
expect(trace.text().split('\n').length).toBe(mockTrace.length);
expect(trace.text().split('\n')).toEqual(mockTrace);
});
it('populates footer', () => {
const footer = findLogFooter().text();
expect(footer).toContain(`${mockLogsResult.length} results`);
});
describe('when user clicks', () => {
it('environment name, trace is refreshed', () => {
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
const index = 1; // any env
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything());
items.at(index).vm.$emit('click');
expect(dispatch).toHaveBeenCalledWith(
`${module}/showEnvironment`,
mockEnvironments[index].name,
);
});
it('refresh button, trace is refreshed', () => {
expect(dispatch).not.toHaveBeenCalledWith(`${module}/refreshPodLogs`, undefined);
findLogControlButtons().vm.$emit('refresh');
expect(dispatch).toHaveBeenCalledWith(`${module}/refreshPodLogs`, undefined);
});
});
});
describe('listeners', () => {
beforeEach(() => {
initWrapper();
});
it('attaches listeners in components', () => {
expect(findInfiniteScroll().vm.$listeners).toEqual({
topReached: expect.any(Function),
scroll: expect.any(Function),
});
});
it('`topReached` when not loading', () => {
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`topReached` does not fetches more logs when already loading', () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`topReached` fetches more logs', () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('topReached');
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
});
it('`scroll` on a scrollable target results in enabled scroll buttons', async () => {
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 21 };
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target });
await nextTick();
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(false);
});
it('`scroll` on a non-scrollable target in disabled scroll buttons', async () => {
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 20 };
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target });
await nextTick();
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
});
it('`scroll` on no target results in disabled scroll buttons', async () => {
state.logs.isLoading = true;
findInfiniteScroll().vm.$emit('scroll', { target: undefined });
await nextTick();
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
});
});
});

View File

@ -1,175 +0,0 @@
import { GlFilteredSearch } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import LogAdvancedFilters from '~/logs/components/log_advanced_filters.vue';
import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
import { createStore } from '~/logs/stores';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import { defaultTimeRange } from '~/vue_shared/constants';
import { mockPods, mockSearch } from '../mock_data';
const module = 'environmentLogs';
describe('LogAdvancedFilters', () => {
let store;
let dispatch;
let wrapper;
let state;
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
const getSearchToken = (type) =>
findFilteredSearch()
.props('availableTokens')
.filter((token) => token.type === type)[0];
const mockStateLoading = () => {
state.timeRange.selected = defaultTimeRange;
state.timeRange.current = convertToFixedRange(defaultTimeRange);
state.pods.options = [];
state.pods.current = null;
state.logs.isLoading = true;
};
const mockStateWithData = () => {
state.timeRange.selected = defaultTimeRange;
state.timeRange.current = convertToFixedRange(defaultTimeRange);
state.pods.options = mockPods;
state.pods.current = null;
state.logs.isLoading = false;
};
const initWrapper = (propsData = {}) => {
wrapper = shallowMount(LogAdvancedFilters, {
propsData: {
...propsData,
},
store,
});
};
beforeEach(() => {
store = createStore();
state = store.state.environmentLogs;
jest.spyOn(store, 'dispatch').mockResolvedValue();
dispatch = store.dispatch;
});
afterEach(() => {
store.dispatch.mockReset();
if (wrapper) {
wrapper.destroy();
}
});
it('displays UI elements', () => {
initWrapper();
expect(findFilteredSearch().exists()).toBe(true);
expect(findTimeRangePicker().exists()).toBe(true);
});
it('displays search tokens', () => {
initWrapper();
expect(getSearchToken(TOKEN_TYPE_POD_NAME)).toMatchObject({
title: 'Pod name',
unique: true,
operators: OPERATOR_IS_ONLY,
});
});
describe('disabled state', () => {
beforeEach(() => {
mockStateLoading();
initWrapper({
disabled: true,
});
});
it('displays disabled filters', () => {
expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
expect(findTimeRangePicker().attributes('disabled')).toBeTruthy();
});
});
describe('when the state is loading', () => {
beforeEach(() => {
mockStateLoading();
initWrapper();
});
it('displays a disabled search', () => {
expect(findFilteredSearch().attributes('disabled')).toBeTruthy();
});
it('displays an enable date filter', () => {
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
});
it('displays no pod options when no pods are available, so suggestions can be displayed', () => {
expect(getSearchToken(TOKEN_TYPE_POD_NAME).options).toBe(null);
expect(getSearchToken(TOKEN_TYPE_POD_NAME).loading).toBe(true);
});
});
describe('when the state has data', () => {
beforeEach(() => {
mockStateWithData();
initWrapper();
});
it('displays a single token for pods', () => {
initWrapper();
const tokens = findFilteredSearch().props('availableTokens');
expect(tokens).toHaveLength(1);
expect(tokens[0].type).toBe(TOKEN_TYPE_POD_NAME);
});
it('displays a enabled filters', () => {
expect(findFilteredSearch().attributes('disabled')).toBeFalsy();
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
});
it('displays options in the pods token', () => {
const { options } = getSearchToken(TOKEN_TYPE_POD_NAME);
expect(options).toHaveLength(mockPods.length);
});
it('displays options in date time picker', () => {
const options = findTimeRangePicker().props('options');
expect(options).toEqual(expect.any(Array));
expect(options.length).toBeGreaterThan(0);
});
describe('when the user interacts', () => {
it('clicks on the search button, showFilteredLogs is dispatched', () => {
findFilteredSearch().vm.$emit('submit', null);
expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, null);
});
it('clicks on the search button, showFilteredLogs is dispatched with null', () => {
findFilteredSearch().vm.$emit('submit', [mockSearch]);
expect(dispatch).toHaveBeenCalledWith(`${module}/showFilteredLogs`, [mockSearch]);
});
it('selects a new time range', () => {
expect(findTimeRangePicker().attributes('disabled')).toBeFalsy();
const mockRange = { start: 'START_DATE', end: 'END_DATE' };
findTimeRangePicker().vm.$emit('input', mockRange);
expect(dispatch).toHaveBeenCalledWith(`${module}/setTimeRange`, mockRange);
});
});
});
});

View File

@ -1,134 +0,0 @@
import { GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import LogSimpleFilters from '~/logs/components/log_simple_filters.vue';
import { createStore } from '~/logs/stores';
import { mockPods, mockPodName } from '../mock_data';
const module = 'environmentLogs';
describe('LogSimpleFilters', () => {
let store;
let dispatch;
let wrapper;
let state;
const findPodsDropdown = () => wrapper.find({ ref: 'podsDropdown' });
const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' });
const findPodsDropdownItems = () =>
findPodsDropdown()
.findAll(GlDropdownItem)
.filter((item) => !('disabled' in item.attributes()));
const mockPodsLoading = () => {
state.pods.options = [];
state.pods.current = null;
};
const mockPodsLoaded = () => {
state.pods.options = mockPods;
state.pods.current = mockPodName;
};
const initWrapper = (propsData = {}) => {
wrapper = shallowMount(LogSimpleFilters, {
propsData: {
...propsData,
},
store,
});
};
beforeEach(() => {
store = createStore();
state = store.state.environmentLogs;
jest.spyOn(store, 'dispatch').mockResolvedValue();
dispatch = store.dispatch;
});
afterEach(() => {
store.dispatch.mockReset();
if (wrapper) {
wrapper.destroy();
}
});
it('displays UI elements', () => {
initWrapper();
expect(findPodsDropdown().exists()).toBe(true);
});
describe('disabled state', () => {
beforeEach(() => {
mockPodsLoading();
initWrapper({
disabled: true,
});
});
it('displays a disabled pods dropdown', () => {
expect(findPodsDropdown().props('text')).toBe('No pod selected');
expect(findPodsDropdown().attributes('disabled')).toBeTruthy();
});
});
describe('loading state', () => {
beforeEach(() => {
mockPodsLoading();
initWrapper();
});
it('displays an enabled pods dropdown', () => {
expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
expect(findPodsDropdown().props('text')).toBe('No pod selected');
});
it('displays an empty pods dropdown', () => {
expect(findPodsNoPodsText().exists()).toBe(true);
expect(findPodsDropdownItems()).toHaveLength(0);
});
});
describe('pods available state', () => {
beforeEach(() => {
mockPodsLoaded();
initWrapper();
});
it('displays an enabled pods dropdown', () => {
expect(findPodsDropdown().attributes('disabled')).toBeFalsy();
expect(findPodsDropdown().props('text')).toBe(mockPods[0]);
});
it('displays a pods dropdown with items', () => {
expect(findPodsNoPodsText().exists()).toBe(false);
expect(findPodsDropdownItems()).toHaveLength(mockPods.length);
});
it('dropdown has one pod selected', () => {
const items = findPodsDropdownItems();
mockPods.forEach((pod, i) => {
const item = items.at(i);
if (item.text() !== mockPodName) {
expect(item.find(GlDropdownItem).attributes('ischecked')).toBeFalsy();
} else {
expect(item.find(GlDropdownItem).attributes('ischecked')).toBeTruthy();
}
});
});
it('when the user clicks on a pod, showPodLogs is dispatched', () => {
const items = findPodsDropdownItems();
const index = 2; // any pod
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
items.at(index).vm.$emit('click');
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]);
});
});
});

View File

@ -1,521 +0,0 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
import { convertToFixedRange } from '~/lib/utils/datetime_range';
import { TOKEN_TYPE_POD_NAME } from '~/logs/constants';
import {
setInitData,
showFilteredLogs,
showPodLogs,
fetchEnvironments,
fetchLogs,
fetchMoreLogsPrepend,
} from '~/logs/stores/actions';
import * as types from '~/logs/stores/mutation_types';
import logsPageState from '~/logs/stores/state';
import Tracking from '~/tracking';
import { defaultTimeRange } from '~/vue_shared/constants';
import {
mockPodName,
mockEnvironmentsEndpoint,
mockEnvironments,
mockPods,
mockLogsResult,
mockEnvName,
mockSearch,
mockLogsEndpoint,
mockResponse,
mockCursor,
mockNextCursor,
} from '../mock_data';
jest.mock('~/lib/utils/datetime_range');
jest.mock('~/logs/utils');
const mockDefaultRange = {
start: '2020-01-10T18:00:00.000Z',
end: '2020-01-10T19:00:00.000Z',
};
const mockFixedRange = {
start: '2020-01-09T18:06:20.000Z',
end: '2020-01-09T18:36:20.000Z',
};
const mockRollingRange = {
duration: 120,
};
const mockRollingRangeAsFixed = {
start: '2020-01-10T18:00:00.000Z',
end: '2020-01-10T17:58:00.000Z',
};
describe('Logs Store actions', () => {
let state;
let mock;
const latestGetParams = () => mock.history.get[mock.history.get.length - 1].params;
convertToFixedRange.mockImplementation((range) => {
if (range === defaultTimeRange) {
return { ...mockDefaultRange };
}
if (range === mockFixedRange) {
return { ...mockFixedRange };
}
if (range === mockRollingRange) {
return { ...mockRollingRangeAsFixed };
}
throw new Error('Invalid time range');
});
beforeEach(() => {
state = logsPageState();
});
describe('setInitData', () => {
it('should commit environment and pod name mutation', () =>
testAction(
setInitData,
{ timeRange: mockFixedRange, environmentName: mockEnvName, podName: mockPodName },
state,
[
{ type: types.SET_TIME_RANGE, payload: mockFixedRange },
{ type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName },
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
],
));
});
describe('showFilteredLogs', () => {
it('empty search should filter with defaults', () =>
testAction(
showFilteredLogs,
undefined,
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: '' },
],
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
));
it('text search should filter with a search term', () =>
testAction(
showFilteredLogs,
[mockSearch],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: mockSearch },
],
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
));
it('pod search should filter with a search term', () =>
testAction(
showFilteredLogs,
[{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: '' },
],
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
));
it('pod search should filter with a pod selection and a search term', () =>
testAction(
showFilteredLogs,
[{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } }, mockSearch],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: mockSearch },
],
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
));
it('pod search should filter with a pod selection and two search terms', () =>
testAction(
showFilteredLogs,
['term1', 'term2'],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: null },
{ type: types.SET_SEARCH, payload: `term1 term2` },
],
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
));
it('pod search should filter with a pod selection and a search terms before and after', () =>
testAction(
showFilteredLogs,
[
'term1',
{ type: TOKEN_TYPE_POD_NAME, value: { data: mockPodName, operator: '=' } },
'term2',
],
state,
[
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.SET_SEARCH, payload: `term1 term2` },
],
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
));
});
describe('showPodLogs', () => {
it('should commit pod name', () =>
testAction(
showPodLogs,
mockPodName,
state,
[{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }],
[{ type: 'fetchLogs', payload: 'pod_log_changed' }],
));
});
describe('fetchEnvironments', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
});
it('should commit RECEIVE_ENVIRONMENTS_DATA_SUCCESS mutation on correct data', () => {
mock.onGet(mockEnvironmentsEndpoint).replyOnce(200, mockEnvironments);
return testAction(
fetchEnvironments,
mockEnvironmentsEndpoint,
state,
[
{ type: types.REQUEST_ENVIRONMENTS_DATA },
{ type: types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, payload: mockEnvironments },
],
[{ type: 'fetchLogs', payload: 'environment_selected' }],
);
});
it('should commit RECEIVE_ENVIRONMENTS_DATA_ERROR on wrong data', () => {
mock.onGet(mockEnvironmentsEndpoint).replyOnce(500);
return testAction(
fetchEnvironments,
mockEnvironmentsEndpoint,
state,
[
{ type: types.REQUEST_ENVIRONMENTS_DATA },
{ type: types.RECEIVE_ENVIRONMENTS_DATA_ERROR },
],
[],
);
});
});
describe('when the backend responds succesfully', () => {
let expectedMutations;
let expectedActions;
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
});
afterEach(() => {
mock.reset();
});
describe('fetchLogs', () => {
beforeEach(() => {
expectedMutations = [
{ type: types.REQUEST_LOGS_DATA },
{
type: types.RECEIVE_LOGS_DATA_SUCCESS,
payload: { logs: mockLogsResult, cursor: mockNextCursor },
},
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
];
expectedActions = [];
});
it('should commit logs and pod data when there is pod name defined', () => {
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toMatchObject({
pod_name: mockPodName,
});
});
});
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
state.logs.cursor = mockCursor;
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
start_time: mockFixedRange.start,
end_time: mockFixedRange.end,
cursor: mockCursor,
});
});
});
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
state.pods.current = mockPodName;
state.search = mockSearch;
state.timeRange.current = 'INVALID_TIME_RANGE';
expectedMutations.splice(1, 0, {
type: types.SHOW_TIME_RANGE_INVALID_WARNING,
});
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
search: mockSearch,
});
});
});
it('should commit logs and pod data when no pod name defined', () => {
state.timeRange.current = defaultTimeRange;
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
expect(latestGetParams()).toEqual({
start_time: expect.any(String),
end_time: expect.any(String),
});
});
});
});
describe('fetchMoreLogsPrepend', () => {
beforeEach(() => {
expectedMutations = [
{ type: types.REQUEST_LOGS_DATA_PREPEND },
{
type: types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS,
payload: { logs: mockLogsResult, cursor: mockNextCursor },
},
];
expectedActions = [];
});
it('should commit logs and pod data when there is pod name defined', () => {
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
expectedActions = [];
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toMatchObject({
pod_name: mockPodName,
});
},
);
});
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
state.pods.current = mockPodName;
state.timeRange.current = mockFixedRange;
state.logs.cursor = mockCursor;
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
start_time: mockFixedRange.start,
end_time: mockFixedRange.end,
cursor: mockCursor,
});
},
);
});
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
state.pods.current = mockPodName;
state.search = mockSearch;
state.timeRange.current = 'INVALID_TIME_RANGE';
expectedMutations.splice(1, 0, {
type: types.SHOW_TIME_RANGE_INVALID_WARNING,
});
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toEqual({
pod_name: mockPodName,
search: mockSearch,
});
},
);
});
it('should commit logs and pod data when no pod name defined', () => {
state.timeRange.current = defaultTimeRange;
return testAction(
fetchMoreLogsPrepend,
null,
state,
expectedMutations,
expectedActions,
() => {
expect(latestGetParams()).toEqual({
start_time: expect.any(String),
end_time: expect.any(String),
});
},
);
});
it('should not commit logs or pod data when it has reached the end', () => {
state.logs.isComplete = true;
state.logs.cursor = null;
return testAction(
fetchMoreLogsPrepend,
null,
state,
[], // no mutations done
[], // no actions dispatched
() => {
expect(mock.history.get).toHaveLength(0);
},
);
});
});
});
describe('when the backend responds with an error', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(mockLogsEndpoint).reply(500);
});
afterEach(() => {
mock.reset();
});
it('fetchLogs should commit logs and pod errors', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.timeRange.current = defaultTimeRange;
return testAction(
fetchLogs,
null,
state,
[
{ type: types.REQUEST_LOGS_DATA },
{ type: types.RECEIVE_PODS_DATA_ERROR },
{ type: types.RECEIVE_LOGS_DATA_ERROR },
],
[],
() => {
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
},
);
});
it('fetchMoreLogsPrepend should commit logs and pod errors', () => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
state.timeRange.current = defaultTimeRange;
return testAction(
fetchMoreLogsPrepend,
null,
state,
[
{ type: types.REQUEST_LOGS_DATA_PREPEND },
{ type: types.RECEIVE_LOGS_DATA_PREPEND_ERROR },
],
[],
() => {
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
},
);
});
});
});
describe('Tracking user interaction', () => {
let commit;
let dispatch;
let state;
let mock;
beforeEach(() => {
jest.spyOn(Tracking, 'event');
commit = jest.fn();
dispatch = jest.fn();
state = logsPageState();
state.environments.options = mockEnvironments;
state.environments.current = mockEnvName;
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.reset();
});
describe('Logs with data', () => {
beforeEach(() => {
mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
});
it('tracks fetched logs with data', () => {
return fetchLogs({ state, commit, dispatch }, 'environment_selected').then(() => {
expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'logs_view', {
label: 'environment_selected',
property: 'count',
value: 1,
});
});
});
});
describe('Logs without data', () => {
beforeEach(() => {
mock.onGet(mockLogsEndpoint).reply(200, {
...mockResponse,
logs: [],
});
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
});
it('does not track empty log responses', () => {
return fetchLogs({ state, commit, dispatch }).then(() => {
expect(Tracking.event).not.toHaveBeenCalled();
});
});
});
});

View File

@ -1,75 +0,0 @@
import { trace, showAdvancedFilters } from '~/logs/stores/getters';
import logsPageState from '~/logs/stores/state';
import { mockLogsResult, mockTrace, mockEnvName, mockEnvironments } from '../mock_data';
describe('Logs Store getters', () => {
let state;
beforeEach(() => {
state = logsPageState();
});
describe('trace', () => {
describe('when state is initialized', () => {
it('returns an empty string', () => {
expect(trace(state)).toEqual('');
});
});
describe('when state logs are empty', () => {
beforeEach(() => {
state.logs.lines = [];
});
it('returns an empty string', () => {
expect(trace(state)).toEqual('');
});
});
describe('when state logs are set', () => {
beforeEach(() => {
state.logs.lines = mockLogsResult;
});
it('returns an empty string', () => {
expect(trace(state)).toEqual(mockTrace.join('\n'));
});
});
});
describe('showAdvancedFilters', () => {
describe('when no environments are set', () => {
beforeEach(() => {
state.environments.current = mockEnvName;
state.environments.options = [];
});
it('returns false', () => {
expect(showAdvancedFilters(state)).toBe(false);
});
});
describe('when the environment supports filters', () => {
beforeEach(() => {
state.environments.current = mockEnvName;
state.environments.options = mockEnvironments;
});
it('returns true', () => {
expect(showAdvancedFilters(state)).toBe(true);
});
});
describe('when the environment does not support filters', () => {
beforeEach(() => {
state.environments.options = mockEnvironments;
state.environments.current = mockEnvironments[1].name;
});
it('returns true', () => {
expect(showAdvancedFilters(state)).toBe(false);
});
});
});
});

View File

@ -1,52 +1,40 @@
import Vue, { nextTick } from 'vue';
import PromptComponent from '~/notebook/cells/prompt.vue';
const Component = Vue.extend(PromptComponent);
import { shallowMount } from '@vue/test-utils';
import Prompt from '~/notebook/cells/prompt.vue';
describe('Prompt component', () => {
let vm;
let wrapper;
const mountComponent = ({ type }) => shallowMount(Prompt, { propsData: { type, count: 1 } });
afterEach(() => {
wrapper.destroy();
});
describe('input', () => {
beforeEach(() => {
vm = new Component({
propsData: {
type: 'In',
count: 1,
},
});
vm.$mount();
return nextTick();
wrapper = mountComponent({ type: 'In' });
});
it('renders in label', () => {
expect(vm.$el.textContent.trim()).toContain('In');
expect(wrapper.text()).toContain('In');
});
it('renders count', () => {
expect(vm.$el.textContent.trim()).toContain('1');
expect(wrapper.text()).toContain('1');
});
});
describe('output', () => {
beforeEach(() => {
vm = new Component({
propsData: {
type: 'Out',
count: 1,
},
});
vm.$mount();
return nextTick();
wrapper = mountComponent({ type: 'Out' });
});
it('renders in label', () => {
expect(vm.$el.textContent.trim()).toContain('Out');
expect(wrapper.text()).toContain('Out');
});
it('renders count', () => {
expect(vm.$el.textContent.trim()).toContain('1');
expect(wrapper.text()).toContain('1');
});
});
});

View File

@ -1,41 +1,30 @@
import Vue from 'vue';
import noteSignedOut from '~/notes/components/note_signed_out_widget.vue';
import { shallowMount } from '@vue/test-utils';
import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue';
import createStore from '~/notes/stores';
import { notesDataMock } from '../mock_data';
describe('note_signed_out_widget component', () => {
let store;
let vm;
describe('NoteSignedOutWidget component', () => {
let wrapper;
beforeEach(() => {
const Component = Vue.extend(noteSignedOut);
store = createStore();
const store = createStore();
store.dispatch('setNotesData', notesDataMock);
vm = new Component({
store,
}).$mount();
wrapper = shallowMount(NoteSignedOutWidget, { store });
});
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
it('should render sign in link provided in the store', () => {
expect(vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent).toEqual(
'sign in',
);
it('renders sign in link provided in the store', () => {
expect(wrapper.find(`a[href="${notesDataMock.newSessionPath}"]`).text()).toBe('sign in');
});
it('should render register link provided in the store', () => {
expect(vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent).toEqual(
'register',
);
it('renders register link provided in the store', () => {
expect(wrapper.find(`a[href="${notesDataMock.registerPath}"]`).text()).toBe('register');
});
it('should render information text', () => {
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual(
'Please register or sign in to reply',
);
it('renders information text', () => {
expect(wrapper.text()).toContain('Please register or sign in to reply');
});
});

View File

@ -1,48 +1,33 @@
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import { FIXTURES_PATH } from 'spec/test_constants';
import PDFLab from '~/pdf/index.vue';
jest.mock('pdfjs-dist/webpack', () => {
return { default: jest.requireActual('pdfjs-dist/build/pdf') };
});
describe('PDFLab component', () => {
let wrapper;
const pdf = `${FIXTURES_PATH}/blob/pdf/test.pdf`;
const mountComponent = ({ pdf }) => shallowMount(PDFLab, { propsData: { pdf } });
const Component = Vue.extend(PDFLab);
describe('PDF component', () => {
let vm;
afterEach(() => {
wrapper.destroy();
});
describe('without PDF data', () => {
beforeEach(() => {
vm = new Component({
propsData: {
pdf: '',
},
});
vm.$mount();
wrapper = mountComponent({ pdf: '' });
});
it('does not render', () => {
expect(vm.$el.tagName).toBeUndefined();
expect(wrapper.isVisible()).toBe(false);
});
});
describe('with PDF data', () => {
beforeEach(() => {
vm = new Component({
propsData: {
pdf,
},
});
vm.$mount();
wrapper = mountComponent({ pdf: `${FIXTURES_PATH}/blob/pdf/test.pdf` });
});
it('renders pdf component', () => {
expect(vm.$el.tagName).toBeDefined();
it('renders', () => {
expect(wrapper.isVisible()).toBe(true);
});
});
});

View File

@ -0,0 +1,44 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import PageSizeSelector, { PAGE_SIZES } from '~/vue_shared/components/page_size_selector.vue';
describe('Page size selector component', () => {
let wrapper;
const createWrapper = ({ pageSize = 20 } = {}) => {
wrapper = shallowMount(PageSizeSelector, {
propsData: { value: pageSize },
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
afterEach(() => {
wrapper.destroy();
});
it.each(PAGE_SIZES)('shows expected text in the dropdown button for page size %s', (pageSize) => {
createWrapper({ pageSize });
expect(findDropdown().props('text')).toBe(`Show ${pageSize} items`);
});
it('shows the expected dropdown items', () => {
createWrapper();
PAGE_SIZES.forEach((pageSize, index) => {
expect(findDropdownItems().at(index).text()).toBe(`Show ${pageSize} items`);
});
});
it('will emit the new page size when a dropdown item is clicked', () => {
createWrapper();
findDropdownItems().wrappers.forEach((itemWrapper, index) => {
itemWrapper.vm.$emit('click');
expect(wrapper.emitted('input')[index][0]).toBe(PAGE_SIZES[index]);
});
});
});

View File

@ -9,6 +9,7 @@ import IssuableItem from '~/vue_shared/issuable/list/components/issuable_item.vu
import IssuableListRoot from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import IssuableTabs from '~/vue_shared/issuable/list/components/issuable_tabs.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import PageSizeSelector from '~/vue_shared/components/page_size_selector.vue';
import { mockIssuableListProps, mockIssuables } from '../mock_data';
@ -44,6 +45,7 @@ describe('IssuableListRoot', () => {
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
const findVueDraggable = () => wrapper.findComponent(VueDraggable);
const findPageSizeSelector = () => wrapper.findComponent(PageSizeSelector);
afterEach(() => {
wrapper.destroy();
@ -292,6 +294,7 @@ describe('IssuableListRoot', () => {
});
expect(findGlKeysetPagination().exists()).toBe(false);
expect(findPageSizeSelector().exists()).toBe(false);
expect(findGlPagination().props()).toMatchObject({
perPage: 20,
value: 1,
@ -483,4 +486,24 @@ describe('IssuableListRoot', () => {
});
});
});
describe('page size selector', () => {
beforeEach(() => {
wrapper = createComponent({
props: {
showPageSizeChangeControls: true,
},
});
});
it('has the page size change component', async () => {
expect(findPageSizeSelector().exists()).toBe(true);
});
it('emits "page-size-change" event when its input is changed', () => {
const pageSize = 123;
findPageSizeSelector().vm.$emit('input', pageSize);
expect(wrapper.emitted('page-size-change')).toEqual([[pageSize]]);
});
});
});

View File

@ -741,7 +741,7 @@ RSpec.describe SearchHelper do
let(:for_group) { true }
it 'adds the :group and :group_metadata correctly to hash' do
expect(header_search_context[:group]).to eq({ id: group.id, name: group.name })
expect(header_search_context[:group]).to eq({ id: group.id, name: group.name, full_name: group.full_name })
expect(header_search_context[:group_metadata]).to eq(group_metadata)
end

View File

@ -15,7 +15,7 @@ RSpec.describe BulkImports::Pipeline::Runner do
Class.new do
def initialize(options = {}); end
def transform(context); end
def transform(context, data); end
end
end
@ -23,7 +23,7 @@ RSpec.describe BulkImports::Pipeline::Runner do
Class.new do
def initialize(options = {}); end
def load(context); end
def load(context, data); end
end
end
@ -44,11 +44,73 @@ RSpec.describe BulkImports::Pipeline::Runner do
end
let_it_be_with_reload(:entity) { create(:bulk_import_entity) }
let_it_be(:tracker) { create(:bulk_import_tracker, entity: entity) }
let_it_be(:context) { BulkImports::Pipeline::Context.new(tracker, extra: :data) }
let(:tracker) { create(:bulk_import_tracker, entity: entity) }
let(:context) { BulkImports::Pipeline::Context.new(tracker, extra: :data) }
subject { BulkImports::MyPipeline.new(context) }
shared_examples 'failed pipeline' do |exception_class, exception_message|
it 'logs import failure' do
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger).to receive(:error)
.with(
log_params(
context,
pipeline_step: :extractor,
pipeline_class: 'BulkImports::MyPipeline',
exception_class: exception_class,
exception_message: exception_message
)
)
end
expect { subject.run }
.to change(entity.failures, :count).by(1)
failure = entity.failures.first
expect(failure).to be_present
expect(failure.pipeline_class).to eq('BulkImports::MyPipeline')
expect(failure.pipeline_step).to eq('extractor')
expect(failure.exception_class).to eq(exception_class)
expect(failure.exception_message).to eq(exception_message)
end
context 'when pipeline is marked to abort on failure' do
before do
BulkImports::MyPipeline.abort_on_failure!
end
it 'logs a warn message and marks entity and tracker as failed' do
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger).to receive(:warn)
.with(
log_params(
context,
message: 'Aborting entity migration due to pipeline failure',
pipeline_class: 'BulkImports::MyPipeline'
)
)
end
subject.run
expect(entity.failed?).to eq(true)
expect(tracker.failed?).to eq(true)
end
end
context 'when pipeline is not marked to abort on failure' do
it 'does not mark entity as failed' do
subject.run
expect(tracker.failed?).to eq(true)
expect(entity.failed?).to eq(false)
end
end
end
describe 'pipeline runner' do
context 'when entity is not marked as failed' do
it 'runs pipeline extractor, transformer, loader' do
@ -145,70 +207,65 @@ RSpec.describe BulkImports::Pipeline::Runner do
end
end
context 'when exception is raised' do
context 'when the exception BulkImports::NetworkError is raised' do
before do
allow_next_instance_of(BulkImports::Extractor) do |extractor|
allow(extractor).to receive(:extract).with(context).and_raise(
BulkImports::NetworkError.new(
'Net::ReadTimeout',
response: instance_double(HTTParty::Response, code: reponse_status_code, headers: {})
)
)
end
end
context 'when exception is retriable' do
let(:reponse_status_code) { 429 }
it 'raises the exception BulkImports::RetryPipelineError' do
expect { subject.run }.to raise_error(BulkImports::RetryPipelineError)
end
end
context 'when exception is not retriable' do
let(:reponse_status_code) { 503 }
it_behaves_like 'failed pipeline', 'BulkImports::NetworkError', 'Net::ReadTimeout'
end
end
context 'when a retriable BulkImports::NetworkError exception is raised while extracting the next page' do
before do
call_count = 0
allow_next_instance_of(BulkImports::Extractor) do |extractor|
allow(extractor).to receive(:extract).with(context).twice do
if call_count.zero?
call_count += 1
extracted_data(has_next_page: true)
else
raise(
BulkImports::NetworkError.new(
response: instance_double(HTTParty::Response, code: 429, headers: {})
)
)
end
end
end
end
it 'raises the exception BulkImports::RetryPipelineError' do
expect { subject.run }.to raise_error(BulkImports::RetryPipelineError)
end
end
context 'when the exception StandardError is raised' do
before do
allow_next_instance_of(BulkImports::Extractor) do |extractor|
allow(extractor).to receive(:extract).with(context).and_raise(StandardError, 'Error!')
end
end
it 'logs import failure' do
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger).to receive(:error)
.with(
log_params(
context,
pipeline_step: :extractor,
pipeline_class: 'BulkImports::MyPipeline',
exception_class: 'StandardError',
exception_message: 'Error!'
)
)
end
expect { subject.run }
.to change(entity.failures, :count).by(1)
failure = entity.failures.first
expect(failure).to be_present
expect(failure.pipeline_class).to eq('BulkImports::MyPipeline')
expect(failure.pipeline_step).to eq('extractor')
expect(failure.exception_class).to eq('StandardError')
expect(failure.exception_message).to eq('Error!')
end
context 'when pipeline is marked to abort on failure' do
before do
BulkImports::MyPipeline.abort_on_failure!
end
it 'logs a warn message and marks entity as failed' do
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger).to receive(:warn)
.with(
log_params(
context,
message: 'Pipeline failed',
pipeline_class: 'BulkImports::MyPipeline'
)
)
end
subject.run
expect(entity.status_name).to eq(:failed)
expect(tracker.status_name).to eq(:failed)
end
end
context 'when pipeline is not marked to abort on failure' do
it 'does not mark entity as failed' do
subject.run
expect(entity.failed?).to eq(false)
end
end
it_behaves_like 'failed pipeline', 'StandardError', 'Error!'
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BulkImports::RetryPipelineError do
describe '#retry_delay' do
it 'returns retry_delay' do
exception = described_class.new('Error!', 60)
expect(exception.retry_delay).to eq(60)
end
end
end

View File

@ -17,10 +17,10 @@ RSpec.describe Gitlab::ErrorTracking::ErrorRepository::OpenApiStrategy do
shared_examples 'exception logging' do
it 'logs error' do
expect(Gitlab::AppLogger).to receive(:error).with(
expect(Gitlab::AppLogger).to receive(:error).with({
'open_api.http_code' => api_exception.code,
'open_api.response_body' => api_exception.response_body.truncate(100)
)
})
subject
end
@ -66,11 +66,11 @@ RSpec.describe Gitlab::ErrorTracking::ErrorRepository::OpenApiStrategy do
.and_return(error)
allow(open_api).to receive(:list_events)
.with(project.id, error.fingerprint, sort: 'occurred_at_asc', limit: 1)
.with(project.id, error.fingerprint, { sort: 'occurred_at_asc', limit: 1 })
.and_return(list_events_asc)
allow(open_api).to receive(:list_events)
.with(project.id, error.fingerprint, sort: 'occurred_at_desc', limit: 1)
.with(project.id, error.fingerprint, { sort: 'occurred_at_desc', limit: 1 })
.and_return(list_events_desc)
end
end

View File

@ -82,11 +82,43 @@ RSpec.describe Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter, :cl
end
describe '.track_approve_mr_action' do
subject { described_class.track_approve_mr_action(user: user) }
include ProjectForksHelper
let(:merge_request) { create(:merge_request, target_project: target_project, source_project: source_project) }
let(:source_project) { fork_project(target_project) }
let(:target_project) { create(:project) }
subject { described_class.track_approve_mr_action(user: user, merge_request: merge_request) }
it_behaves_like 'a tracked merge request unique event' do
let(:action) { described_class::MR_APPROVE_ACTION }
end
it 'records correct payload with Snowplow event', :snowplow do
stub_feature_flags(route_hll_to_snowplow_phase2: true)
subject
expect_snowplow_event(
category: 'merge_requests',
action: 'i_code_review_user_approve_mr',
namespace: target_project.namespace,
user: user,
project: target_project
)
end
context 'when FF is disabled' do
before do
stub_feature_flags(route_hll_to_snowplow_phase2: false)
end
it 'doesnt emit snowplow events', :snowplow do
subject
expect_no_snowplow_event
end
end
end
describe '.track_unapprove_mr_action' do

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe Issues::RelatedBranchesService do
RSpec.describe Issues::RelatedBranchesService, :clean_gitlab_redis_cache do
let_it_be(:developer) { create(:user) }
let_it_be(:issue) { create(:issue) }

View File

@ -90,7 +90,7 @@ RSpec.describe MergeRequests::ApprovalService do
it 'tracks merge request approve action' do
expect(Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter)
.to receive(:track_approve_mr_action).with(user: user)
.to receive(:track_approve_mr_action).with(user: user, merge_request: merge_request)
service.execute(merge_request)
end

View File

@ -25,8 +25,12 @@ RSpec.describe Projects::DestroyService, :aggregate_failures, :event_store_publi
expect(project.gitlab_shell.repository_exists?(project.repository_storage, path + '.git')).to be_falsey
end
it 'publishes a ProjectDeleted event with project id and namespace id' do
expected_data = { project_id: project.id, namespace_id: project.namespace_id }
it 'publishes a ProjectDeletedEvent' do
expected_data = {
project_id: project.id,
namespace_id: project.namespace_id,
root_namespace_id: project.root_namespace.id
}
expect { destroy_project(project, user, {}) }.to publish_event(Projects::ProjectDeletedEvent).with(expected_data)
end

View File

@ -189,7 +189,7 @@ RSpec.describe BulkImports::PipelineWorker do
end
end
context 'when network error is raised' do
context 'when retry pipeline error is raised' do
let(:pipeline_tracker) do
create(
:bulk_import_tracker,
@ -200,7 +200,7 @@ RSpec.describe BulkImports::PipelineWorker do
end
let(:exception) do
BulkImports::NetworkError.new(response: instance_double(HTTParty::Response, code: 429, headers: {}))
BulkImports::RetryPipelineError.new('Error!', 60)
end
before do
@ -213,54 +213,36 @@ RSpec.describe BulkImports::PipelineWorker do
end
end
context 'when error is retriable' do
it 'reenqueues the worker' do
expect_any_instance_of(BulkImports::Tracker) do |tracker|
expect(tracker).to receive(:retry).and_call_original
end
it 'reenqueues the worker' do
expect_any_instance_of(BulkImports::Tracker) do |tracker|
expect(tracker).to receive(:retry).and_call_original
end
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger)
.to receive(:info)
.with(
hash_including(
'pipeline_name' => 'FakePipeline',
'entity_id' => entity.id
)
)
end
expect(described_class)
.to receive(:perform_in)
expect_next_instance_of(Gitlab::Import::Logger) do |logger|
expect(logger)
.to receive(:info)
.with(
60.seconds,
pipeline_tracker.id,
pipeline_tracker.stage,
pipeline_tracker.entity.id
hash_including(
'pipeline_name' => 'FakePipeline',
'entity_id' => entity.id
)
)
subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id)
pipeline_tracker.reload
expect(pipeline_tracker.enqueued?).to be_truthy
end
context 'when error is not retriable' do
let(:exception) do
BulkImports::NetworkError.new(response: instance_double(HTTParty::Response, code: 503, headers: {}))
end
expect(described_class)
.to receive(:perform_in)
.with(
60.seconds,
pipeline_tracker.id,
pipeline_tracker.stage,
pipeline_tracker.entity.id
)
it 'marks tracker as failed and logs the error' do
expect(described_class).not_to receive(:perform_in)
subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id)
subject.perform(pipeline_tracker.id, pipeline_tracker.stage, entity.id)
pipeline_tracker.reload
pipeline_tracker.reload
expect(pipeline_tracker.failed?).to eq(true)
end
end
expect(pipeline_tracker.enqueued?).to be_truthy
end
end
end