Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-02-24 15:15:02 +00:00
parent e40c68997d
commit c4b4a75c35
52 changed files with 1020 additions and 311 deletions

View File

@ -626,7 +626,7 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab
/doc/raketasks/x509_signatures.md @aqualls
/doc/security/ @eread
/doc/ssh/index.md @eread
/doc/subscriptions/ @sselhorn
/doc/subscriptions/ @fneill
/doc/system_hooks/system_hooks.md @kpaizee
/doc/topics/authentication/index.md @eread
/doc/topics/autodevops/customize.md @marcia
@ -791,5 +791,5 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab
/doc/user/snippets.md @aqualls
/doc/user/tasks.md @msedlakjakubowski
/doc/user/todos.md @msedlakjakubowski
/doc/user/usage_quotas.md @sselhorn
/doc/user/usage_quotas.md @fneill
/doc/user/workspace/index.md @fneill

View File

@ -1 +1 @@
63abf93ad828f7a7924f3e0bb1fea8ea43d7c6af
c35fba1c073deed91d1a1f9f11dd668856841d80

View File

@ -1,31 +0,0 @@
import $ from 'jquery';
export default class AjaxLoadingSpinner {
static init() {
const $elements = $('.js-ajax-loading-spinner');
$elements.on('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
}
static ajaxBeforeSend(e) {
const button = e.target;
const newButton = document.createElement('button');
newButton.classList.add('btn', 'btn-default', 'disabled', 'gl-button');
newButton.setAttribute('disabled', 'disabled');
const spinner = document.createElement('span');
spinner.classList.add('align-text-bottom', 'gl-spinner', 'gl-spinner-sm', 'gl-spinner-orange');
newButton.appendChild(spinner);
button.classList.add('hidden');
button.parentNode.insertBefore(newButton, button.nextSibling);
$(button).one('ajax:error', () => {
newButton.remove();
button.classList.remove('hidden');
});
$(button).one('ajax:success', () => {
$(button).off('ajax:beforeSend', AjaxLoadingSpinner.ajaxBeforeSend);
});
}
}

View File

@ -1,7 +1,6 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
import { createContentEditor } from '../services/create_content_editor';
import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue';
@ -55,17 +54,12 @@ export default {
extensions,
serializerConfig,
});
this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
},
mounted() {
this.$emit('initialized', this.contentEditor);
},
beforeDestroy() {
this.contentEditor.dispose();
this.contentEditor.off(LOADING_CONTENT_EVENT, this.displayLoadingIndicator);
this.contentEditor.off(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
this.contentEditor.off(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
},
methods: {
displayLoadingIndicator() {
@ -91,7 +85,14 @@ export default {
<template>
<content-editor-provider :content-editor="contentEditor">
<div>
<editor-state-observer @docUpdate="notifyChange" @focus="focus" @blur="blur" />
<editor-state-observer
@loading="displayLoadingIndicator"
@loadingSuccess="hideLoadingIndicator"
@loadingError="hideLoadingIndicator"
@docUpdate="notifyChange"
@focus="focus"
@blur="blur"
/>
<content-editor-alert />
<div
data-testid="content-editor"

View File

@ -8,6 +8,7 @@ export default {
return {
contentEditor,
eventHub: contentEditor.eventHub,
tiptapEditor: contentEditor.tiptapEditor,
};
},

View File

@ -1,5 +1,11 @@
<script>
import { debounce } from 'lodash';
import {
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
ALERT_EVENT,
} from '../constants';
export const tiptapToComponentMap = {
update: 'docUpdate',
@ -7,30 +13,48 @@ export const tiptapToComponentMap = {
transaction: 'transaction',
focus: 'focus',
blur: 'blur',
alert: 'alert',
};
export const eventHubEvents = [
ALERT_EVENT,
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
];
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
export default {
inject: ['tiptapEditor'],
inject: ['tiptapEditor', 'eventHub'],
created() {
this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => {
const eventHandler = debounce((params) => this.handleTipTapEvent(tiptapEvent, params), 100);
const eventHandler = debounce(
(params) => this.bubbleEvent(getComponentEventName(tiptapEvent), params),
100,
);
this.tiptapEditor?.on(tiptapEvent, eventHandler);
this.disposables.push(() => this.tiptapEditor?.off(tiptapEvent, eventHandler));
});
eventHubEvents.forEach((event) => {
const handler = (...params) => {
this.bubbleEvent(event, ...params);
};
this.eventHub.$on(event, handler);
this.disposables.push(() => this.eventHub?.$off(event, handler));
});
},
beforeDestroy() {
this.disposables.forEach((dispose) => dispose());
},
methods: {
handleTipTapEvent(tiptapEvent, params) {
this.$emit(getComponentEventName(tiptapEvent), params);
bubbleEvent(eventHubEvent, params) {
this.$emit(eventHubEvent, params);
},
},
render() {

View File

@ -42,9 +42,10 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
},
];
export const LOADING_CONTENT_EVENT = 'loadingContent';
export const LOADING_CONTENT_EVENT = 'loading';
export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
export const LOADING_ERROR_EVENT = 'loadingError';
export const ALERT_EVENT = 'alert';
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
@ -56,3 +57,4 @@ export const EXTENSION_PRIORITY_LOWER = 75;
* https://tiptap.dev/guide/custom-extensions/#priority
*/
export const EXTENSION_PRIORITY_DEFAULT = 100;
export const EXTENSION_PRIORITY_HIGHEST = 200;

View File

@ -9,15 +9,22 @@ export default Extension.create({
return {
uploadsPath: null,
renderMarkdown: null,
eventHub: null,
};
},
addCommands() {
return {
uploadAttachment: ({ file }) => () => {
const { uploadsPath, renderMarkdown } = this.options;
const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({ file, uploadsPath, renderMarkdown, editor: this.editor });
return handleFileEvent({
file,
uploadsPath,
renderMarkdown,
editor: this.editor,
eventHub,
});
},
};
},
@ -29,23 +36,25 @@ export default Extension.create({
key: new PluginKey('attachment'),
props: {
handlePaste: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({
editor,
file: event.clipboardData.files[0],
uploadsPath,
renderMarkdown,
eventHub,
});
},
handleDrop: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options;
const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({
editor,
file: event.dataTransfer.files[0],
uploadsPath,
renderMarkdown,
eventHub,
});
},
},

View File

@ -1,17 +1,20 @@
import eventHubFactory from '~/helpers/event_hub_factory';
import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
constructor({ tiptapEditor, serializer }) {
constructor({ tiptapEditor, serializer, eventHub }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._eventHub = eventHubFactory();
this._eventHub = eventHub;
}
get tiptapEditor() {
return this._tiptapEditor;
}
get eventHub() {
return this._eventHub;
}
get empty() {
const doc = this.tiptapEditor?.state.doc;
@ -23,39 +26,23 @@ export class ContentEditor {
this.tiptapEditor.destroy();
}
once(type, handler) {
this._eventHub.$once(type, handler);
}
on(type, handler) {
this._eventHub.$on(type, handler);
}
emit(type, params = {}) {
this._eventHub.$emit(type, params);
}
off(type, handler) {
this._eventHub.$off(type, handler);
}
disposeAllEvents() {
this._eventHub.dispose();
}
async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer } = this;
const { _tiptapEditor: editor, _serializer: serializer, _eventHub: eventHub } = this;
try {
this._eventHub.$emit(LOADING_CONTENT_EVENT);
eventHub.$emit(LOADING_CONTENT_EVENT);
const document = await serializer.deserialize({
schema: editor.schema,
content: serializedContent,
});
editor.commands.setContent(document);
this._eventHub.$emit(LOADING_SUCCESS_EVENT);
eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) {
this._eventHub.$emit(LOADING_ERROR_EVENT, e);
eventHub.$emit(LOADING_ERROR_EVENT, e);
throw e;
}
}

View File

@ -1,5 +1,6 @@
import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment';
import Audio from '../extensions/audio';
@ -78,8 +79,10 @@ export const createContentEditor = ({
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
}
const eventHub = eventHubFactory();
const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown }),
Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio,
Blockquote,
Bold,
@ -137,5 +140,5 @@ export const createContentEditor = ({
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig });
return new ContentEditor({ tiptapEditor, serializer });
return new ContentEditor({ tiptapEditor, serializer, eventHub });
};

View File

@ -49,7 +49,7 @@ export const uploadFile = async ({ uploadsPath, renderMarkdown, file }) => {
return extractAttachmentLinkUrl(rendered);
};
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const encodedSrc = await readFileAsDataURL(file);
const { view } = editor;
@ -72,14 +72,14 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
);
} catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 });
editor.emit('alert', {
eventHub.$emit('alert', {
message: __('An error occurred while uploading the image. Please try again.'),
variant: 'danger',
});
}
};
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => {
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
await Promise.resolve();
const { view } = editor;
@ -103,23 +103,23 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
);
} catch (e) {
editor.commands.deleteRange({ from, to: from + 1 });
editor.emit('alert', {
eventHub.$emit('alert', {
message: __('An error occurred while uploading the file. Please try again.'),
variant: 'danger',
});
}
};
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => {
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false;
if (acceptedMimes.image.includes(file?.type)) {
uploadImage({ editor, file, uploadsPath, renderMarkdown });
uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
}
uploadAttachment({ editor, file, uploadsPath, renderMarkdown });
uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true;
};

View File

@ -288,7 +288,6 @@ export default {
'sidebar-collapsed': !isSidebarOpen,
'has-archived-block': job.archived,
}"
:erase-path="job.erase_path"
:size="jobLogSize"
:raw-path="job.raw_path"
:is-scroll-bottom-disabled="isScrollBottomDisabled"
@ -325,6 +324,7 @@ export default {
'right-sidebar-expanded': isSidebarOpen,
'right-sidebar-collapsed': !isSidebarOpen,
}"
:erase-path="job.erase_path"
:artifact-help-url="artifactHelpUrl"
data-testid="job-sidebar"
/>

View File

@ -5,7 +5,6 @@ import { __, s__, sprintf } from '~/locale';
export default {
i18n: {
eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'),
showRawButtonLabel: s__('Job|Show complete raw'),
@ -18,11 +17,6 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
erasePath: {
type: String,
required: false,
default: null,
},
size: {
type: Number,
required: true,
@ -97,20 +91,6 @@ export default {
data-testid="job-raw-link-controller"
icon="doc-text"
/>
<gl-button
v-if="erasePath"
v-gl-tooltip.body
:title="$options.i18n.eraseLogButtonLabel"
:aria-label="$options.i18n.eraseLogButtonLabel"
:href="erasePath"
:data-confirm="__('Are you sure you want to erase this build?')"
class="gl-ml-3"
data-testid="job-log-erase-link"
data-confirm-btn-variant="danger"
data-method="post"
icon="remove"
/>
<!-- eo links -->
<!-- scroll buttons -->

View File

@ -1,5 +1,5 @@
<script>
import { GlButton, GlLink, GlModalDirective } from '@gitlab/ui';
import { GlButton, GlModalDirective } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import { JOB_SIDEBAR } from '../constants';
@ -10,7 +10,6 @@ export default {
},
components: {
GlButton,
GlLink,
},
directives: {
GlModal: GlModalDirective,
@ -37,9 +36,18 @@ export default {
:aria-label="$options.i18n.retryLabel"
category="primary"
variant="confirm"
>{{ $options.i18n.retryLabel }}</gl-button
>
<gl-link v-else :href="href" class="btn gl-button btn-confirm" data-method="post" rel="nofollow"
>{{ $options.i18n.retryLabel }}
</gl-link>
icon="retry"
data-testid="retry-job-button"
/>
<gl-button
v-else
:href="href"
:aria-label="$options.i18n.retryLabel"
category="primary"
variant="confirm"
icon="retry"
data-method="post"
data-testid="retry-job-link"
/>
</template>

View File

@ -1,7 +1,8 @@
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import { s__ } from '~/locale';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
import { JOB_SIDEBAR } from '../constants';
import ArtifactsBlock from './artifacts_block.vue';
@ -18,10 +19,17 @@ export const forwardDeploymentFailureModalId = 'forward-deployment-failure';
export default {
name: 'JobSidebar',
i18n: {
eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'),
cancelJobButtonLabel: s__('Job|Cancel'),
retryJobButtonLabel: s__('Job|Retry'),
...JOB_SIDEBAR,
},
borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
forwardDeploymentFailureModalId,
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
ArtifactsBlock,
CommitBlock,
@ -41,6 +49,11 @@ export default {
required: false,
default: '',
},
erasePath: {
type: String,
required: false,
default: null,
},
},
computed: {
...mapGetters(['hasForwardDeploymentFailure']),
@ -81,8 +94,24 @@ export default {
</h4>
</tooltip-on-truncate>
<div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
<gl-button
v-if="erasePath"
v-gl-tooltip.left
:title="$options.i18n.eraseLogButtonLabel"
:aria-label="$options.i18n.eraseLogButtonLabel"
:href="erasePath"
:data-confirm="$options.i18n.eraseLogConfirmText"
class="gl-mr-2"
data-testid="job-log-erase-link"
data-confirm-btn-variant="danger"
data-method="post"
icon="remove"
/>
<job-sidebar-retry-button
v-if="job.retry_path"
v-gl-tooltip.left
:title="$options.i18n.retryJobButtonLabel"
:aria-label="$options.i18n.retryJobButtonLabel"
:category="retryButtonCategory"
:href="job.retry_path"
:modal-id="$options.forwardDeploymentFailureModalId"
@ -92,12 +121,15 @@ export default {
/>
<gl-button
v-if="job.cancel_path"
v-gl-tooltip.left
:title="$options.i18n.cancelJobButtonLabel"
:aria-label="$options.i18n.cancelJobButtonLabel"
:href="job.cancel_path"
icon="cancel"
data-method="post"
data-testid="cancel-button"
rel="nofollow"
>{{ $options.i18n.cancel }}
</gl-button>
/>
</div>
<gl-button

View File

@ -1,12 +1,9 @@
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import initDiverganceGraph from '~/branches/divergence_graph';
import initDeleteBranchButton from '~/branches/init_delete_branch_button';
import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
AjaxLoadingSpinner.init();
const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
'.js-branch-list',
).dataset;

View File

@ -0,0 +1,195 @@
<script>
import { uniqueId } from 'lodash';
import { GlButton, GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
import { s__ } from '~/locale';
const VALIDATION_STATE = {
NO_VALIDATION: null,
INVALID: false,
VALID: true,
};
export const i18n = {
addStepButtonLabel: s__('PipelineWizardListWidget|add another step'),
removeStepButtonLabel: s__('PipelineWizardListWidget|remove step'),
invalidFeedback: s__('PipelineWizardInputValidation|This value is not valid'),
errors: {
needsAnyValueError: s__('PipelineWizardInputValidation|At least one entry is required'),
},
};
export default {
i18n,
name: 'ListWidget',
components: {
GlButton,
GlFormGroup,
GlFormInputGroup,
},
props: {
label: {
type: String,
required: true,
},
description: {
type: String,
required: false,
default: null,
},
placeholder: {
type: String,
required: false,
default: null,
},
default: {
type: Array,
required: false,
default: null,
},
invalidFeedback: {
type: String,
required: false,
default: i18n.invalidFeedback,
},
id: {
type: String,
required: false,
default: () => uniqueId('listWidget-'),
},
pattern: {
type: String,
required: false,
default: null,
},
required: {
type: Boolean,
required: false,
default: false,
},
validate: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
touched: false,
value: this.default ? this.default.map(this.getAsValueEntry) : [this.getAsValueEntry(null)],
};
},
computed: {
sanitizedValue() {
// Filter out empty steps
return this.value.filter(({ value }) => Boolean(value)).map(({ value }) => value) || [];
},
hasAnyValue() {
return this.value.some(({ value }) => Boolean(value));
},
needsAnyValue() {
return this.required && !this.value.some(({ value }) => Boolean(value));
},
inputFieldStates() {
return this.value.map(this.getValidationStateForValue);
},
inputGroupState() {
return this.showValidationState
? this.inputFieldStates.every((v) => v !== VALIDATION_STATE.INVALID)
: VALIDATION_STATE.NO_VALIDATION;
},
showValidationState() {
return this.touched || this.validate;
},
feedback() {
return this.needsAnyValue
? this.$options.i18n.errors.needsAnyValueError
: this.invalidFeedback;
},
},
async created() {
if (this.default) {
// emit an updated default value
await this.$nextTick();
this.$emit('input', this.sanitizedValue);
}
},
methods: {
addInputField() {
this.value.push(this.getAsValueEntry(null));
},
getAsValueEntry(value) {
return {
id: uniqueId('listValue-'),
value,
};
},
getValidationStateForValue({ value }, fieldIndex) {
// If we require a value to be set, mark the first
// field as invalid, but not all of them.
if (this.needsAnyValue && fieldIndex === 0) return VALIDATION_STATE.INVALID;
if (!value) return VALIDATION_STATE.NO_VALIDATION;
return this.passesPatternValidation(value)
? VALIDATION_STATE.VALID
: VALIDATION_STATE.INVALID;
},
passesPatternValidation(v) {
return !this.pattern || new RegExp(this.pattern).test(v);
},
async onValueUpdate() {
await this.$nextTick();
this.$emit('input', this.sanitizedValue);
},
onTouch() {
this.touched = true;
},
removeValue(index) {
this.value.splice(index, 1);
this.onValueUpdate();
},
},
};
</script>
<template>
<div class="gl-mb-6">
<gl-form-group
:invalid-feedback="feedback"
:label="label"
:label-description="description"
:state="inputGroupState"
class="gl-mb-2"
>
<gl-form-input-group
v-for="(item, i) in value"
:key="item.id"
v-model.trim="value[i].value"
:placeholder="i === 0 ? placeholder : undefined"
:state="inputFieldStates[i]"
class="gl-mb-2"
type="text"
@blur="onTouch"
@input="onValueUpdate"
>
<template v-if="value.length > 1" #append>
<gl-button
:aria-label="$options.i18n.removeStepButtonLabel"
category="secondary"
data-testid="remove-step-button"
icon="remove"
@click="removeValue"
/>
</template>
</gl-form-input-group>
</gl-form-group>
<gl-button
category="tertiary"
data-testid="add-step-button"
icon="plus"
size="small"
variant="confirm"
@click="addInputField"
>
{{ $options.i18n.addStepButtonLabel }}
</gl-button>
</div>
</template>

View File

@ -31,15 +31,21 @@ class Namespace
# ActiveRecord. https://github.com/rails/rails/issues/13496
# Ideally it would be:
# `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
sql = """
UPDATE namespaces
SET traversal_ids = cte.traversal_ids
FROM (#{recursive_traversal_ids}) as cte
WHERE namespaces.id = cte.id
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
"""
sql = <<-SQL
UPDATE namespaces
SET traversal_ids = cte.traversal_ids
FROM (#{recursive_traversal_ids}) as cte
WHERE namespaces.id = cte.id
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
SQL
Namespace.transaction do
@root.lock!
if Feature.enabled?(:for_no_key_update_lock, default_enabled: :yaml)
@root.lock!("FOR NO KEY UPDATE")
else
@root.lock!
end
Namespace.connection.exec_query(sql)
end
rescue ActiveRecord::Deadlocked

View File

@ -60,7 +60,7 @@ module ErrorTracking
end
def actor
return event['transaction'] if event['transaction']
return event['transaction'] if event['transaction'].present?
# Some SDKs do not have a transaction attribute.
# So we build it by combining function name and module name from

View File

@ -0,0 +1,8 @@
---
name: for_no_key_update_lock
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81239
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353619
milestone: '14.9'
type: development
group: group::workspaces
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: use_received_header_for_incoming_emails
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81489
rollout_issue_url:
milestone: '14.9'
type: development
group: group::certify
default_enabled: true

View File

@ -121,8 +121,8 @@ Then create policies that allow you to read these secrets (one for each secret):
$ vault policy write myproject-staging - <<EOF
# Policy name: myproject-staging
#
# Read-only permission on 'secret/data/myproject/staging/*' path
path "secret/data/myproject/staging/*" {
# Read-only permission on 'secret/myproject/staging/*' path
path "secret/myproject/staging/*" {
capabilities = [ "read" ]
}
EOF
@ -131,8 +131,8 @@ Success! Uploaded policy: myproject-staging
$ vault policy write myproject-production - <<EOF
# Policy name: myproject-production
#
# Read-only permission on 'secret/data/myproject/production/*' path
path "secret/data/myproject/production/*" {
# Read-only permission on 'secret/myproject/production/*' path
path "secret/myproject/production/*" {
capabilities = [ "read" ]
}
EOF

View File

@ -140,7 +140,7 @@ to its **Pipelines** tab.
![Pipelines index page](img/pipelines_index_v13_0.png)
Click a pipeline to open the **Pipeline Details** page and show
Select a pipeline to open the **Pipeline Details** page and show
the jobs that were run for that pipeline. From here you can cancel a running pipeline,
retry jobs on a failed pipeline, or [delete a pipeline](#delete-a-pipeline).
@ -246,7 +246,7 @@ For each `var` or `file_var`, a key and value are required.
[Manual jobs](../jobs/job_control.md#create-a-job-that-must-be-run-manually),
allow you to require manual interaction before moving forward in the pipeline.
You can do this straight from the pipeline graph. Just click the play button
You can do this straight from the pipeline graph. Just select the play button
to execute that particular job.
For example, your pipeline can start automatically, but require a manual action to
@ -259,8 +259,8 @@ In the example below, the `production` stage has a job with a manual action:
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/27188) in GitLab 11.11.
Multiple manual actions in a single stage can be started at the same time using the "Play all manual" button.
After you click this button, each individual manual action is triggered and refreshed
Multiple manual actions in a single stage can be started at the same time using the "Play all manual"
After you select this action, each individual manual action is triggered and refreshed
to an updated status.
This functionality is only available:
@ -283,9 +283,9 @@ pipelines.
Users with the Owner role for a project can delete a pipeline
by clicking on the pipeline in the **CI/CD > Pipelines** to get to the **Pipeline Details**
page, then using the **Delete** button.
page, then selecting **Delete**.
![Pipeline Delete Button](img/pipeline-delete.png)
![Pipeline Delete](img/pipeline-delete.png)
WARNING:
Deleting a pipeline expires all pipeline caches, and deletes all related objects,
@ -314,7 +314,7 @@ sensitive information like deployment credentials and tokens.
**Runners** marked as **protected** can run jobs only on protected
branches, preventing untrusted code from executing on the protected runner and
preserving deployment keys and other credentials from being unintentionally
accessed. In order to ensure that jobs intended to be executed on protected
accessed. To ensure that jobs intended to be executed on protected
runners do not use regular runners, they must be tagged accordingly.
### How pipeline duration is calculated
@ -434,7 +434,7 @@ fix it.
Pipeline mini graphs only display jobs by stage.
Stages in pipeline mini graphs are collapsible. Hover your mouse over them and click to expand their jobs.
Stages in pipeline mini graphs are expandable. Hover your mouse over each stage to see the name and status, and select a stage to expand its jobs list.
| Mini graph | Mini graph expanded |
|:-------------------------------------------------------------|:---------------------------------------------------------------|

View File

@ -1191,7 +1191,7 @@ has a longer discussion explaining the potential problems.
To prevent writes to the Git repository data, there are two possible approaches:
- Use [maintenance mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)** to place GitLab in a read-only state.
- Use [maintenance mode](../administration/maintenance_mode/index.md) to place GitLab in a read-only state.
- Create explicit downtime by stopping all Gitaly services before backing up the repositories:
```shell
@ -1354,15 +1354,13 @@ To prepare the new server:
```shell
sudo rm -f /var/opt/gitlab/redis/dump.rdb
sudo chown <your-linux-username> /var/opt/gitlab/redis
sudo mkdir /var/opt/gitlab/backups
sudo chown <your-linux-username> /var/opt/gitlab/backups
sudo chown <your-linux-username> /var/opt/gitlab/redis /var/opt/gitlab/backups
```
### Prepare and transfer content from the old server
1. Ensure you have an up-to-date system-level backup or snapshot of the old server.
1. Enable [maintenance mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)**,
1. Enable [maintenance mode](../administration/maintenance_mode/index.md),
if supported by your GitLab edition.
1. Block new CI/CD jobs from starting:
1. Edit `/etc/gitlab/gitlab.rb`, and set the following:
@ -1465,7 +1463,7 @@ To prepare the new server:
1. While still under the Sidekiq dashboard, select **Cron** and then **Enable All**
to re-enable periodic background jobs.
1. Test that read-only operations on the GitLab instance work as expected. For example, browse through project repository files, merge requests, and issues.
1. Disable [Maintenance Mode](../administration/maintenance_mode/index.md) **(PREMIUM SELF)**, if previously enabled.
1. Disable [Maintenance Mode](../administration/maintenance_mode/index.md), if previously enabled.
1. Test that the GitLab instance is working as expected.
1. If applicable, re-enable [incoming email](../administration/incoming_email.md) and test it is working as expected.
1. Update your DNS or load balancer to point at the new server.

View File

@ -1,34 +1,9 @@
---
stage: Enablement
group: Distribution
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
redirect_to: 'index.md'
remove_date: '2022-05-24'
---
# Namespaces **(FREE SELF)**
This document was moved to [another location](index.md).
This Rake task enables [namespaces](../user/group/index.md#namespaces) for projects.
## Enable usernames and namespaces for user projects
This command enables the namespaces feature. It moves every project in its
namespace folder.
The **repository location changes as part of this task**, so you must **update all your Git URLs** to
point to the new location.
To change your username:
1. In the top-right corner, select your avatar.
1. Select **Edit profile**.
1. On the left sidebar, select **Account**.
1. In the **Change username** section, type the new username.
1. Select **Update username**.
For example:
- Old path: `git@example.org:myrepo.git`.
- New path: `git@example.org:username/myrepo.git` or `git@example.org:groupname/myrepo.git`.
```shell
bundle exec rake gitlab:enable_namespaces RAILS_ENV=production
```
<!-- This redirect file can be deleted after <2022-05-24>. -->
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page -->

View File

@ -12,12 +12,49 @@ instance entirely offline.
## Installation
NOTE:
This guide assumes the server is Ubuntu 18.04. Instructions for other servers may vary.
This guide also assumes the server host resolves as `my-host`, which you should replace with your
server's name.
This guide assumes the server is Ubuntu 20.04 using the [Omnibus installation method](https://docs.gitlab.com/omnibus/) and will be running GitLab [Enterprise Edition](https://about.gitlab.com/install/ce-or-ee/). Instructions for other servers may vary.
This guide also assumes the server host resolves as `my-host.internal`, which you should replace with your
server's FQDN, and that you have acess to a different server with Internet access to download the required package files.
Follow the installation instructions [as outlined in the omnibus install
guide](https://about.gitlab.com/install/#ubuntu), but make sure to specify an `http`
<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
For a video walkthrough of this process, see [Offline GitLab Installation: Downloading & Installing](https://www.youtube.com/watch?v=TJaq4ua2Prw).
### Download the GitLab package
You should [manually download the GitLab package](../../update/package/index.md#upgrade-using-a-manually-downloaded-package) and relevant dependencies using a server of the same operating system type that has access to the Internet.
If your offline environment has no local network access, you must manually transport across the relevant package files through physical media, such as a USB drive or writable DVD.
In Ubuntu, this can be performed on a server with Internet access using the following commands:
```shell
# Download the bash script to prepare the repository
curl --silent "https://packages.gitlab.com/install/repositories/gitlab/gitlab-ee/script.deb.sh" | sudo bash
# Download the gitlab-ee package and dependencies to /var/cache/apt/archives
sudo apt-get install --download-only gitlab-ee
# Copy the contents of the apt download folder to a mounted media device
sudo cp /var/cache/apt/archives/*.deb /path/to/mount
```
### Install the GitLab package
Prerequisites:
- Before installing the GitLab package on your offline environment, ensure that you have installed all required dependencies first.
If you are using Ubuntu, you can install the dependency `.deb` packages you copied across with `dpkg`. Do not install the GitLab package yet.
```shell
# Navigate to the physical media device
sudo cd /path/to/mount
# Install the dependency packages
sudo dpkg -i <package_name>.deb
```
[Use the relevant commands for your operating system to install the package](../../update/package/index.md#upgrade-using-a-manually-downloaded-package) but make sure to specify an `http`
URL for the `EXTERNAL_URL` installation step. Once installed, we can manually
configure the SSL ourselves.
@ -25,8 +62,10 @@ It is strongly recommended to setup a domain for IP resolution rather than bind
to the server's IP address. This better ensures a stable target for our certs' CN
and makes long-term resolution simpler.
The following example for Ubuntu specifies the `EXTERNAL_URL` using HTTP and installs the GitLab package:
```shell
sudo EXTERNAL_URL="http://my-host.internal" apt-get install gitlab-ee
sudo EXTERNAL_URL="http://my-host.internal" dpkg -i <gitlab_package_name>.deb
```
## Enabling SSL
@ -38,7 +77,7 @@ Follow these steps to enable SSL for your fresh instance. Note that these steps
```ruby
# Update external_url from "http" to "https"
external_url "https://gitlab.example.com"
external_url "https://my-host.internal"
# Set Let's Encrypt to false
letsencrypt['enable'] = false

View File

@ -75,4 +75,9 @@ Access our [permissions](../../permissions.md) page for more information.
When you [add a linked issue](#add-a-linked-issue), you can show that it **blocks** or
**is blocked by** another issue.
Issues that block other issues have an icon (**{issue-block}**) shown in the issue lists and [boards](../issue_board.md).
Issues that block other issues have an icon (**{issue-block}**) next to their title, shown in the
issue lists and [boards](../issue_board.md).
The icon disappears when the blocking issue is closed or their relationship is changed or
[removed](#remove-a-linked-issue).
If you try to close a blocked issue using the "Close issue" button, a confirmation message appears.

View File

@ -8,6 +8,8 @@ module Gitlab
class Receiver
include Gitlab::Utils::StrongMemoize
RECEIVED_HEADER_REGEX = /for\s+\<(.+)\>/.freeze
def initialize(raw)
@raw = raw
end
@ -37,6 +39,8 @@ module Gitlab
delivered_to: delivered_to.map(&:value),
envelope_to: envelope_to.map(&:value),
x_envelope_to: x_envelope_to.map(&:value),
# reduced down to what looks like an email in the received headers
received_recipients: recipients_from_received_headers,
meta: {
client_id: "email/#{mail.from.first}",
project: handler&.project&.full_path
@ -82,7 +86,8 @@ module Gitlab
find_key_from_references ||
find_key_from_delivered_to_header ||
find_key_from_envelope_to_header ||
find_key_from_x_envelope_to_header
find_key_from_x_envelope_to_header ||
find_first_key_from_received_headers
end
def ensure_references_array(references)
@ -117,6 +122,10 @@ module Gitlab
Array(mail[:x_envelope_to])
end
def received
Array(mail[:received])
end
def find_key_from_delivered_to_header
delivered_to.find do |header|
key = email_class.key_from_address(header.value)
@ -138,6 +147,21 @@ module Gitlab
end
end
def find_first_key_from_received_headers
return unless ::Feature.enabled?(:use_received_header_for_incoming_emails, default_enabled: :yaml)
recipients_from_received_headers.find do |email|
key = email_class.key_from_address(email)
break key if key
end
end
def recipients_from_received_headers
strong_memoize :emails_from_received_headers do
received.map { |header| header.value[RECEIVED_HEADER_REGEX, 1] }.compact
end
end
def ignore_auto_reply!
if auto_submitted? || auto_replied?
raise AutoGeneratedEmailError

View File

@ -4738,9 +4738,6 @@ msgstr ""
msgid "Are you sure you want to discard your changes?"
msgstr ""
msgid "Are you sure you want to erase this build?"
msgstr ""
msgid "Are you sure you want to import %d repository?"
msgid_plural "Are you sure you want to import %d repositories?"
msgstr[0] ""
@ -21031,9 +21028,15 @@ msgstr ""
msgid "Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. Retrying this job could result in overwriting the environment with the older source code."
msgstr ""
msgid "Job|Are you sure you want to erase this job log and artifacts?"
msgstr ""
msgid "Job|Browse"
msgstr ""
msgid "Job|Cancel"
msgstr ""
msgid "Job|Complete Raw"
msgstr ""
@ -21061,6 +21064,9 @@ msgstr ""
msgid "Job|Pipeline"
msgstr ""
msgid "Job|Retry"
msgstr ""
msgid "Job|Scroll to bottom"
msgstr ""
@ -26813,12 +26819,21 @@ msgstr ""
msgid "PipelineWizardDefaultCommitMessage|Update %{filename}"
msgstr ""
msgid "PipelineWizardInputValidation|At least one entry is required"
msgstr ""
msgid "PipelineWizardInputValidation|This field is required"
msgstr ""
msgid "PipelineWizardInputValidation|This value is not valid"
msgstr ""
msgid "PipelineWizardListWidget|add another step"
msgstr ""
msgid "PipelineWizardListWidget|remove step"
msgstr ""
msgid "PipelineWizard|Commit"
msgstr ""

View File

@ -313,7 +313,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
context 'job is cancelable' do
it 'shows cancel button' do
click_link 'Cancel'
find('[data-testid="cancel-button"]').click
expect(page.current_path).to eq(job_url)
end
@ -1031,7 +1031,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
end
it 'loads the page and shows all needed controls' do
expect(page).to have_content 'Retry'
expect(page).to have_selector('[data-testid="retry-button"')
end
end
end
@ -1049,7 +1049,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
it 'shows the right status and buttons' do
page.within('aside.right-sidebar') do
expect(page).to have_content 'Cancel'
expect(page).to have_selector('[data-testid="cancel-button"')
end
end
end

View File

@ -0,0 +1,35 @@
Return-Path: <jake@example.com>
Received: from myserver.example.com ([unix socket]) by myserver (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
Received: from blabla.google.com (blabla.google.com. [1.1.1.1])
by bla.google.com with SMTPS id something.1.1.1.1.1.1.1
for <incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com>
(Google Transport Security);
Mon, 21 Feb 2022 14:41:58 -0800 (PST)
Received: from mail.example.com (mail.example.com [IPv6:2607:f8b0:4001:c03::234]) by myserver.example.com (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@example.com>; Thu, 13 Jun 2013 17:03:50 -0400
From: "jake@example.com" <jake@example.com>
To: "support@example.com" <support@example.com>
Subject: Insert hilarious subject line here
Date: Tue, 26 Nov 2019 14:22:41 +0000
Message-ID: <7e2296f83dbf4de388cbf5f56f52c11f@EXDAG29-1.EXCHANGE.INT>
Accept-Language: de-DE, en-US
Content-Language: de-DE
X-MS-Has-Attach:
X-MS-TNEF-Correlator:
x-ms-exchange-transport-fromentityheader: Hosted
x-originating-ip: [62.96.54.178]
Content-Type: multipart/alternative;
boundary="_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_"
MIME-Version: 1.0
--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_
Content-Type: text/plain; charset="iso-8859-1"
Content-Transfer-Encoding: quoted-printable
--_000_7e2296f83dbf4de388cbf5f56f52c11fEXDAG291EXCHANGEINT_
Content-Type: text/html; charset="iso-8859-1"
Content-Transfer-Encoding: quoted-printable
Look, a message with no Delivered-To header! Let's fallback to Received: in case it's there.

View File

@ -1,6 +1,6 @@
Return-Path: <jake@adventuretime.ooo>
Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400
Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq/gitlabhq@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for <incoming+gitlabhq-gitlabhq-project_id-auth_token-issue-issue_iid@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 17:03:50 -0400
Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for <incoming+gitlabhq-gitlabhq-project_id-auth_token-issue-issue_iid@appmail.adventuretime.ooo>; Thu, 13 Jun 2013 14:03:48 -0700
Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700
Date: Thu, 13 Jun 2013 17:03:48 -0400

View File

@ -0,0 +1,45 @@
{
"event_id": "dquJXuPF9sP1fMy5RpKo979xUALjNDQB",
"timestamp": 1645191605.123456,
"platform": "php",
"sdk": {
"name": "sentry.php",
"version": "3.3.7"
},
"logger": "php",
"transaction": "",
"server_name": "oAjA5zTgIjqP",
"release": "C0FFEE",
"environment": "Development/Berlin",
"exception": {
"values": [
{
"type": "TestException",
"value": "Sentry test exception",
"stacktrace": {
"frames": [
{
"filename": "/src/Path/To/Class.php",
"lineno": 3,
"in_app": true,
"abs_path": "/var/www/html/src/Path/To/Class.php",
"function": "Path\\To\\Class::method",
"raw_function": "Path\\To\\Class::method",
"pre_context": [
"// Pre-context"
],
"context_line": "throw new TestException('Sentry test exception');",
"post_context": [
"// Post-context"
]
}
]
},
"mechanism": {
"type": "generic",
"handled": true
}
}
]
}
}

View File

@ -1,32 +0,0 @@
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
describe('Ajax Loading Spinner', () => {
let ajaxLoadingSpinnerElement;
let fauxEvent;
beforeEach(() => {
document.body.innerHTML = `
<div>
<a class="js-ajax-loading-spinner"
data-remote
href="http://goesnowhere.nothing/whereami">
Remove me
</a></div>`;
AjaxLoadingSpinner.init();
ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner');
fauxEvent = { target: ajaxLoadingSpinnerElement };
});
afterEach(() => {
document.body.innerHTML = '';
});
it('`ajaxBeforeSend` event handler sets current icon to spinner and disables link', () => {
expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).toBeNull();
expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(false);
AjaxLoadingSpinner.ajaxBeforeSend(fauxEvent);
expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).not.toBeNull();
expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(true);
});
});

View File

@ -3,20 +3,25 @@ import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import { createTestEditor, emitEditorEvent } from '../test_utils';
import eventHubFactory from '~/helpers/event_hub_factory';
import { ALERT_EVENT } from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/content_editor_alert', () => {
let wrapper;
let tiptapEditor;
let eventHub;
const findErrorAlert = () => wrapper.findComponent(GlAlert);
const createWrapper = async () => {
tiptapEditor = createTestEditor();
eventHub = eventHubFactory();
wrapper = shallowMountExtended(ContentEditorAlert, {
provide: {
tiptapEditor,
eventHub,
},
stubs: {
EditorStateObserver,
@ -37,7 +42,9 @@ describe('content_editor/components/content_editor_alert', () => {
async ({ message, variant }) => {
createWrapper();
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } });
eventHub.$emit(ALERT_EVENT, { message, variant });
await nextTick();
expect(findErrorAlert().text()).toBe(message);
expect(findErrorAlert().attributes().variant).toBe(variant);
@ -48,11 +55,9 @@ describe('content_editor/components/content_editor_alert', () => {
const message = 'error message';
createWrapper();
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } });
eventHub.$emit(ALERT_EVENT, { message });
await nextTick();
findErrorAlert().vm.$emit('dismiss');
await nextTick();
expect(findErrorAlert().exists()).toBe(false);

View File

@ -121,7 +121,7 @@ describe('ContentEditor', () => {
beforeEach(async () => {
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
});
@ -143,9 +143,9 @@ describe('ContentEditor', () => {
beforeEach(async () => {
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
contentEditor.emit(LOADING_SUCCESS_EVENT);
contentEditor.eventHub.$emit(LOADING_SUCCESS_EVENT);
await nextTick();
});
@ -164,9 +164,9 @@ describe('ContentEditor', () => {
beforeEach(async () => {
createWrapper();
contentEditor.emit(LOADING_CONTENT_EVENT);
contentEditor.eventHub.$emit(LOADING_CONTENT_EVENT);
await nextTick();
contentEditor.emit(LOADING_ERROR_EVENT, error);
contentEditor.eventHub.$emit(LOADING_ERROR_EVENT, error);
await nextTick();
});

View File

@ -3,6 +3,13 @@ import { each } from 'lodash';
import EditorStateObserver, {
tiptapToComponentMap,
} from '~/content_editor/components/editor_state_observer.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
import {
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
ALERT_EVENT,
} from '~/content_editor/constants';
import { createTestEditor } from '../test_utils';
describe('content_editor/components/editor_state_observer', () => {
@ -11,19 +18,29 @@ describe('content_editor/components/editor_state_observer', () => {
let onDocUpdateListener;
let onSelectionUpdateListener;
let onTransactionListener;
let onLoadingContentListener;
let onLoadingSuccessListener;
let onLoadingErrorListener;
let onAlertListener;
let eventHub;
const buildEditor = () => {
tiptapEditor = createTestEditor();
eventHub = eventHubFactory();
jest.spyOn(tiptapEditor, 'on');
};
const buildWrapper = () => {
wrapper = shallowMount(EditorStateObserver, {
provide: { tiptapEditor },
provide: { tiptapEditor, eventHub },
listeners: {
docUpdate: onDocUpdateListener,
selectionUpdate: onSelectionUpdateListener,
transaction: onTransactionListener,
[ALERT_EVENT]: onAlertListener,
[LOADING_CONTENT_EVENT]: onLoadingContentListener,
[LOADING_SUCCESS_EVENT]: onLoadingSuccessListener,
[LOADING_ERROR_EVENT]: onLoadingErrorListener,
},
});
};
@ -32,8 +49,11 @@ describe('content_editor/components/editor_state_observer', () => {
onDocUpdateListener = jest.fn();
onSelectionUpdateListener = jest.fn();
onTransactionListener = jest.fn();
onAlertListener = jest.fn();
onLoadingSuccessListener = jest.fn();
onLoadingContentListener = jest.fn();
onLoadingErrorListener = jest.fn();
buildEditor();
buildWrapper();
});
afterEach(() => {
@ -44,6 +64,8 @@ describe('content_editor/components/editor_state_observer', () => {
it('emits update, selectionUpdate, and transaction events', () => {
const content = '<p>My paragraph</p>';
buildWrapper();
tiptapEditor.commands.insertContent(content);
expect(onDocUpdateListener).toHaveBeenCalledWith(
@ -58,10 +80,27 @@ describe('content_editor/components/editor_state_observer', () => {
});
});
it.each`
event | listener
${ALERT_EVENT} | ${() => onAlertListener}
${LOADING_CONTENT_EVENT} | ${() => onLoadingContentListener}
${LOADING_SUCCESS_EVENT} | ${() => onLoadingSuccessListener}
${LOADING_ERROR_EVENT} | ${() => onLoadingErrorListener}
`('listens to $event event in the eventBus object', ({ event, listener }) => {
const args = {};
buildWrapper();
eventHub.$emit(event, args);
expect(listener()).toHaveBeenCalledWith(args);
});
describe('when component is destroyed', () => {
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
jest.spyOn(tiptapEditor, 'off');
buildWrapper();
wrapper.destroy();
each(tiptapToComponentMap, (_, tiptapEvent) => {
@ -71,5 +110,25 @@ describe('content_editor/components/editor_state_observer', () => {
);
});
});
it.each`
event
${ALERT_EVENT}
${LOADING_CONTENT_EVENT}
${LOADING_SUCCESS_EVENT}
${LOADING_ERROR_EVENT}
`('removes $event event hook from eventHub', ({ event }) => {
jest.spyOn(eventHub, '$off');
jest.spyOn(eventHub, '$on');
buildWrapper();
wrapper.destroy();
expect(eventHub.$off).toHaveBeenCalledWith(
event,
eventHub.$on.mock.calls.find(([eventName]) => eventName === event)[1],
);
});
});
});

View File

@ -2,6 +2,7 @@ import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_button', () => {
@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => {
},
provide: {
tiptapEditor,
eventHub: eventHubFactory(),
},
propsData: {
contentType: CONTENT_TYPE,

View File

@ -1,6 +1,7 @@
import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
import Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => {
wrapper = mountExtended(ToolbarLinkButton, {
provide: {
tiptapEditor: editor,
eventHub: eventHubFactory(),
},
});
};

View File

@ -4,6 +4,7 @@ import EditorStateObserver from '~/content_editor/components/editor_state_observ
import ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
import Heading from '~/content_editor/extensions/heading';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_text_style_dropdown', () => {
@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
},
provide: {
tiptapEditor,
eventHub: eventHubFactory(),
},
propsData: {
...propsData,

View File

@ -5,6 +5,7 @@ import Image from '~/content_editor/extensions/image';
import Link from '~/content_editor/extensions/link';
import Loading from '~/content_editor/extensions/loading';
import httpStatus from '~/lib/utils/http_status';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, createDocBuilder } from '../test_utils';
const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `<p data-sourcepos="1:1-1:27" dir="auto">
@ -25,6 +26,7 @@ describe('content_editor/extensions/attachment', () => {
let link;
let renderMarkdown;
let mock;
let eventHub;
const uploadsPath = '/uploads/';
const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' });
@ -50,9 +52,15 @@ describe('content_editor/extensions/attachment', () => {
beforeEach(() => {
renderMarkdown = jest.fn();
eventHub = eventHubFactory();
tiptapEditor = createTestEditor({
extensions: [Loading, Link, Image, Attachment.configure({ renderMarkdown, uploadsPath })],
extensions: [
Loading,
Link,
Image,
Attachment.configure({ renderMarkdown, uploadsPath, eventHub }),
],
});
({
@ -160,7 +168,7 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: imageFile });
tiptapEditor.on('alert', ({ message }) => {
eventHub.$on('alert', ({ message }) => {
expect(message).toBe('An error occurred while uploading the image. Please try again.');
done();
});
@ -236,7 +244,7 @@ describe('content_editor/extensions/attachment', () => {
it('emits an alert event that includes an error message', (done) => {
tiptapEditor.commands.uploadAttachment({ file: attachmentFile });
tiptapEditor.on('alert', ({ message }) => {
eventHub.$on('alert', ({ message }) => {
expect(message).toBe('An error occurred while uploading the file. Please try again.');
done();
});

View File

@ -4,19 +4,21 @@ import {
LOADING_ERROR_EVENT,
} from '~/content_editor/constants';
import { ContentEditor } from '~/content_editor/services/content_editor';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor } from '../test_utils';
describe('content_editor/services/content_editor', () => {
let contentEditor;
let serializer;
let eventHub;
beforeEach(() => {
const tiptapEditor = createTestEditor();
jest.spyOn(tiptapEditor, 'destroy');
serializer = { deserialize: jest.fn() };
contentEditor = new ContentEditor({ tiptapEditor, serializer });
eventHub = eventHubFactory();
contentEditor = new ContentEditor({ tiptapEditor, serializer, eventHub });
});
describe('.dispose', () => {
@ -34,13 +36,13 @@ describe('content_editor/services/content_editor', () => {
serializer.deserialize.mockResolvedValueOnce('');
});
it('emits loadingContent and loadingSuccess event', () => {
it('emits loadingContent and loadingSuccess event in the eventHub', () => {
let loadingContentEmitted = false;
contentEditor.on(LOADING_CONTENT_EVENT, () => {
eventHub.$on(LOADING_CONTENT_EVENT, () => {
loadingContentEmitted = true;
});
contentEditor.on(LOADING_SUCCESS_EVENT, () => {
eventHub.$on(LOADING_SUCCESS_EVENT, () => {
expect(loadingContentEmitted).toBe(true);
});
@ -56,7 +58,7 @@ describe('content_editor/services/content_editor', () => {
});
it('emits loadingError event', async () => {
contentEditor.on(LOADING_ERROR_EVENT, (e) => {
eventHub.$on(LOADING_ERROR_EVENT, (e) => {
expect(e).toBe('error');
});

View File

@ -8,7 +8,6 @@ describe('Job log controllers', () => {
afterEach(() => {
if (wrapper?.destroy) {
wrapper.destroy();
wrapper = null;
}
});
@ -34,7 +33,6 @@ describe('Job log controllers', () => {
const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]');
const findRawLink = () => wrapper.find('[data-testid="raw-link"]');
const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]');
const findEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]');
const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]');
const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]');
@ -76,28 +74,6 @@ describe('Job log controllers', () => {
expect(findRawLinkController().exists()).toBe(false);
});
});
describe('when is erasable', () => {
beforeEach(() => {
createWrapper();
});
it('renders erase job link', () => {
expect(findEraseLink().exists()).toBe(true);
});
});
describe('when it is not erasable', () => {
beforeEach(() => {
createWrapper({
erasePath: null,
});
});
it('does not render erase button', () => {
expect(findEraseLink().exists()).toBe(false);
});
});
});
describe('scroll buttons', () => {

View File

@ -1,5 +1,4 @@
import { GlButton, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue';
import createStore from '~/jobs/store';
import job from '../mock_data';
@ -9,12 +8,12 @@ describe('Job Sidebar Retry Button', () => {
let wrapper;
const forwardDeploymentFailure = 'forward_deployment_failure';
const findRetryButton = () => wrapper.find(GlButton);
const findRetryLink = () => wrapper.find(GlLink);
const findRetryButton = () => wrapper.findByTestId('retry-job-button');
const findRetryLink = () => wrapper.findByTestId('retry-job-link');
const createWrapper = ({ props = {} } = {}) => {
store = createStore();
wrapper = shallowMount(JobsSidebarRetryButton, {
wrapper = shallowMountExtended(JobsSidebarRetryButton, {
propsData: {
href: job.retry_path,
modalId: 'modal-id',
@ -27,7 +26,6 @@ describe('Job Sidebar Retry Button', () => {
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
});
@ -44,7 +42,6 @@ describe('Job Sidebar Retry Button', () => {
expect(findRetryButton().exists()).toBe(buttonExists);
expect(findRetryLink().exists()).toBe(linkExists);
expect(wrapper.text()).toMatch('Retry');
},
);
@ -55,6 +52,7 @@ describe('Job Sidebar Retry Button', () => {
expect(findRetryButton().attributes()).toMatchObject({
category: 'primary',
variant: 'confirm',
icon: 'retry',
});
});
});
@ -64,6 +62,7 @@ describe('Job Sidebar Retry Button', () => {
expect(findRetryLink().attributes()).toMatchObject({
'data-method': 'post',
href: job.retry_path,
icon: 'retry',
});
});
});

View File

@ -21,25 +21,54 @@ describe('Sidebar details block', () => {
const findNewIssueButton = () => wrapper.findByTestId('job-new-issue');
const findRetryButton = () => wrapper.find(JobRetryButton);
const findTerminalLink = () => wrapper.findByTestId('terminal-link');
const findEraseLink = () => wrapper.findByTestId('job-log-erase-link');
const createWrapper = ({ props = {} } = {}) => {
const createWrapper = (props) => {
store = createStore();
store.state.job = job;
wrapper = extendedWrapper(
shallowMount(Sidebar, {
...props,
propsData: {
...props,
},
store,
}),
);
};
afterEach(() => {
if (wrapper) {
wrapper.destroy();
wrapper = null;
}
wrapper.destroy();
});
describe('when job log is erasable', () => {
const path = '/root/ci-project/-/jobs/1447/erase';
beforeEach(() => {
createWrapper({
erasePath: path,
});
});
it('renders erase job link', () => {
expect(findEraseLink().exists()).toBe(true);
});
it('erase job link has correct path', () => {
expect(findEraseLink().attributes('href')).toBe(path);
});
});
describe('when job log is not erasable', () => {
beforeEach(() => {
createWrapper();
});
it('does not render erase button', () => {
expect(findEraseLink().exists()).toBe(false);
});
});
describe('when there is no retry path retry', () => {
@ -86,7 +115,7 @@ describe('Sidebar details block', () => {
});
it('should render link to cancel job', () => {
expect(findCancelButton().text()).toMatch('Cancel');
expect(findCancelButton().props('icon')).toBe('cancel');
expect(findCancelButton().attributes('href')).toBe(job.cancel_path);
});
});

View File

@ -0,0 +1,212 @@
import { GlFormGroup, GlFormInputGroup } from '@gitlab/ui';
import { nextTick } from 'vue';
import ListWidget from '~/pipeline_wizard/components/widgets/list.vue';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('Pipeline Wizard - List Widget', () => {
const defaultProps = {
label: 'This label',
description: 'some description',
placeholder: 'some placeholder',
pattern: '^[a-z]+$',
invalidFeedback: 'some feedback',
};
let wrapper;
let addStepBtn;
const findGlFormGroup = () => wrapper.findComponent(GlFormGroup);
const findGlFormGroupInvalidFeedback = () => findGlFormGroup().find('.invalid-feedback').text();
const findFirstGlFormInputGroup = () => wrapper.findComponent(GlFormInputGroup);
const findAllGlFormInputGroups = () => wrapper.findAllComponents(GlFormInputGroup);
const findGlFormInputGroupByIndex = (index) => findAllGlFormInputGroups().at(index);
const setValueOnInputField = (value, atIndex = 0) => {
return findGlFormInputGroupByIndex(atIndex).vm.$emit('input', value);
};
const findAddStepButton = () => wrapper.findByTestId('add-step-button');
const addStep = () => findAddStepButton().vm.$emit('click');
const createComponent = (props = {}, mountFn = shallowMountExtended) => {
wrapper = mountFn(ListWidget, {
propsData: {
...defaultProps,
...props,
},
});
addStepBtn = findAddStepButton();
};
describe('component setup and interface', () => {
afterEach(() => {
wrapper.destroy();
});
it('prints the label inside the legend', () => {
createComponent();
expect(findGlFormGroup().attributes('label')).toBe(defaultProps.label);
});
it('prints the description inside the legend', () => {
createComponent();
expect(findGlFormGroup().attributes('labeldescription')).toBe(defaultProps.description);
});
it('sets the input field type attribute to "text"', async () => {
createComponent();
expect(findFirstGlFormInputGroup().attributes('type')).toBe('text');
});
it('passes the placeholder to the first input field', () => {
createComponent();
expect(findFirstGlFormInputGroup().attributes('placeholder')).toBe(defaultProps.placeholder);
});
it('shows a delete button on all fields if there are more than one', async () => {
createComponent({}, mountExtended);
await addStep();
await addStep();
const inputGroups = findAllGlFormInputGroups().wrappers;
expect(inputGroups.length).toBe(3);
inputGroups.forEach((inputGroup) => {
const button = inputGroup.find('[data-testid="remove-step-button"]');
expect(button.find('[data-testid="remove-icon"]').exists()).toBe(true);
expect(button.attributes('aria-label')).toBe('remove step');
});
});
it('null values do not cause an input event', async () => {
createComponent();
await addStep();
expect(wrapper.emitted('input')).toBe(undefined);
});
it('hides the delete button if there is only one', () => {
createComponent({}, mountExtended);
const inputGroups = findAllGlFormInputGroups().wrappers;
expect(inputGroups.length).toBe(1);
expect(wrapper.findByTestId('remove-step-button').exists()).toBe(false);
});
it('shows an "add step" button', () => {
createComponent();
expect(addStepBtn.attributes('icon')).toBe('plus');
expect(addStepBtn.text()).toBe('add another step');
});
it('the "add step" button increases the number of input fields', async () => {
createComponent();
expect(findAllGlFormInputGroups().wrappers.length).toBe(1);
await addStep();
expect(findAllGlFormInputGroups().wrappers.length).toBe(2);
});
it('does not pass the placeholder on subsequent input fields', async () => {
createComponent();
await addStep();
await addStep();
const nullOrUndefined = [null, undefined];
expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(1).attributes('placeholder'));
expect(nullOrUndefined).toContain(findAllGlFormInputGroups().at(2).attributes('placeholder'));
});
it('emits an update event on input', async () => {
createComponent();
const localValue = 'somevalue';
await setValueOnInputField(localValue);
await nextTick();
expect(wrapper.emitted('input')).toEqual([[[localValue]]]);
});
it('only emits non-null values', async () => {
createComponent();
await addStep();
await addStep();
await setValueOnInputField('abc', 1);
await nextTick();
const events = wrapper.emitted('input');
expect(events.length).toBe(1);
expect(events[0]).toEqual([['abc']]);
});
});
describe('form validation', () => {
afterEach(() => {
wrapper.destroy();
});
it('does not show validation state when untouched', async () => {
createComponent({}, mountExtended);
expect(findGlFormGroup().classes()).not.toContain('is-valid');
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
});
it('shows invalid state on blur', async () => {
createComponent({}, mountExtended);
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
const input = findFirstGlFormInputGroup().find('input');
await input.setValue('invalid99');
await input.trigger('blur');
expect(input.classes()).toContain('is-invalid');
expect(findGlFormGroup().classes()).toContain('is-invalid');
});
it('shows invalid state when toggling `validate` prop', async () => {
createComponent({ required: true, validate: false }, mountExtended);
await setValueOnInputField(null);
expect(findGlFormGroup().classes()).not.toContain('is-invalid');
await wrapper.setProps({ validate: true });
expect(findGlFormGroup().classes()).toContain('is-invalid');
});
it.each`
scenario | required | values | inputFieldClasses | inputGroupClass | feedback
${'shows invalid if all inputs are empty'} | ${true} | ${[null, null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${'At least one entry is required'}
${'is valid if at least one field has a valid entry'} | ${true} | ${[null, 'abc']} | ${[null, 'is-valid']} | ${'is-valid'} | ${expect.anything()}
${'is invalid if one field has an invalid entry'} | ${true} | ${['abc', '99']} | ${['is-valid', 'is-invalid']} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
${'is not invalid if its not required but all values are null'} | ${false} | ${[null, null]} | ${[null, null]} | ${'is-valid'} | ${expect.anything()}
${'is invalid if pattern does not match even if its not required'} | ${false} | ${['99', null]} | ${['is-invalid', null]} | ${'is-invalid'} | ${defaultProps.invalidFeedback}
`('$scenario', async ({ required, values, inputFieldClasses, inputGroupClass, feedback }) => {
createComponent({ required, validate: true }, mountExtended);
await Promise.all(
values.map(async (value, i) => {
if (i > 0) {
await addStep();
}
await setValueOnInputField(value, i);
}),
);
await nextTick();
inputFieldClasses.forEach((expected, i) => {
const inputWrapper = findGlFormInputGroupByIndex(i).find('input');
if (expected === null) {
expect(inputWrapper.classes()).not.toContain('is-valid');
expect(inputWrapper.classes()).not.toContain('is-invalid');
} else {
expect(inputWrapper.classes()).toContain(expected);
}
});
expect(findGlFormGroup().classes()).toContain(inputGroupClass);
expect(findGlFormGroupInvalidFeedback()).toEqual(feedback);
});
});
});

View File

@ -32,12 +32,21 @@ RSpec.describe Gitlab::Email::Receiver do
metadata = receiver.mail_metadata
expect(metadata.keys).to match_array(%i(mail_uid from_address to_address mail_key references delivered_to envelope_to x_envelope_to meta))
expect(metadata.keys).to match_array(%i(mail_uid from_address to_address mail_key references delivered_to envelope_to x_envelope_to meta received_recipients))
expect(metadata[:meta]).to include(client_id: 'email/jake@example.com', project: project.full_path)
expect(metadata[meta_key]).to eq(meta_value)
end
end
shared_examples 'failed receive' do
it 'adds metric event' do
expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction)
expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name })
expect { receiver.execute }.to raise_error(expected_error)
end
end
context 'when the email contains a valid email address in a header' do
before do
stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.example.com")
@ -74,14 +83,25 @@ RSpec.describe Gitlab::Email::Receiver do
it_behaves_like 'successful receive'
end
end
shared_examples 'failed receive' do
it 'adds metric event' do
expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction)
expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name })
context 'when all other headers are missing' do
let(:email_raw) { fixture_file('emails/missing_delivered_to_header.eml') }
let(:meta_key) { :received_recipients }
let(:meta_value) { ['incoming+gitlabhq/gitlabhq+auth_token@appmail.example.com', 'incoming+gitlabhq/gitlabhq@example.com'] }
expect { receiver.execute }.to raise_error(expected_error)
context 'when use_received_header_for_incoming_emails is enabled' do
it_behaves_like 'successful receive'
end
context 'when use_received_header_for_incoming_emails is disabled' do
let(:expected_error) { Gitlab::Email::UnknownIncomingEmail }
before do
stub_feature_flags(use_received_header_for_incoming_emails: false)
end
it_behaves_like 'failed receive'
end
end
end

View File

@ -385,23 +385,43 @@ RSpec.describe Group do
end
end
before do
subject
reload_models(old_parent, new_parent, group)
end
context 'within the same hierarchy' do
let!(:root) { create(:group).reload }
let!(:old_parent) { create(:group, parent: root) }
let!(:new_parent) { create(:group, parent: root) }
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
context 'with FOR UPDATE lock' do
before do
stub_feature_flags(for_no_key_update_lock: false)
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
end
it_behaves_like 'hierarchy with traversal_ids'
it_behaves_like 'locked row', 'FOR UPDATE' do
let(:row) { root }
end
end
it_behaves_like 'hierarchy with traversal_ids'
it_behaves_like 'locked row' do
let(:row) { root }
context 'with FOR NO KEY UPDATE lock' do
before do
stub_feature_flags(for_no_key_update_lock: true)
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [root.id, new_parent.id, group.id]
end
it_behaves_like 'hierarchy with traversal_ids'
it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do
let(:row) { root }
end
end
end
@ -410,6 +430,11 @@ RSpec.describe Group do
let!(:new_parent) { create(:group) }
let!(:group) { create(:group, parent: old_parent) }
before do
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [new_parent.id, group.id]
end
@ -435,6 +460,11 @@ RSpec.describe Group do
let!(:old_parent) { nil }
let!(:new_parent) { create(:group) }
before do
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [new_parent.id, group.id]
end
@ -452,6 +482,11 @@ RSpec.describe Group do
let!(:old_parent) { create(:group) }
let!(:new_parent) { nil }
before do
subject
reload_models(old_parent, new_parent, group)
end
it 'updates traversal_ids' do
expect(group.traversal_ids).to eq [group.id]
end

View File

@ -68,11 +68,24 @@ RSpec.describe Namespace::TraversalHierarchy, type: :model do
end
end
it_behaves_like 'locked row' do
it_behaves_like 'locked row', 'FOR UPDATE' do
let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
let(:row) { root }
before do
stub_feature_flags(for_no_key_update_lock: false)
recorded_queries.record { subject }
end
end
it_behaves_like 'locked row', 'FOR NO KEY UPDATE' do
let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
let(:row) { root }
before do
stub_feature_flags(for_no_key_update_lock: true)
recorded_queries.record { subject }
end
end

View File

@ -171,6 +171,12 @@ RSpec.describe API::ErrorTracking::Collector do
it_behaves_like 'successful request'
end
context 'when JSON key transaction is empty string' do
let_it_be(:raw_event) { fixture_file('error_tracking/php_empty_transaction.json') }
it_behaves_like 'successful request'
end
context 'sentry_key as param and empty headers' do
let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" }
let(:headers) { {} }

View File

@ -51,25 +51,30 @@ RSpec.describe ErrorTracking::CollectErrorService do
end
end
context 'unusual payload' do
context 'with unusual payload' do
let(:modified_event) { parsed_event }
let(:event) { described_class.new(project, nil, event: modified_event).execute }
context 'missing transaction' do
context 'when transaction is missing' do
it 'builds actor from stacktrace' do
modified_event.delete('transaction')
event = described_class.new(project, nil, event: modified_event).execute
expect(event.error.actor).to eq 'find()'
end
end
context 'timestamp is numeric' do
context 'when transaction is an empty string' do \
it 'builds actor from stacktrace' do
modified_event['transaction'] = ''
expect(event.error.actor).to eq 'find()'
end
end
context 'when timestamp is numeric' do
it 'parses timestamp' do
modified_event['timestamp'] = '1631015580.50'
event = described_class.new(project, nil, event: modified_event).execute
expect(event.occurred_at).to eq '2021-09-07T11:53:00.5'
end
end

View File

@ -4,10 +4,10 @@
# Ensure a transaction also occurred.
# Be careful! This form of spec is not foolproof, but better than nothing.
RSpec.shared_examples 'locked row' do
RSpec.shared_examples 'locked row' do |lock_type|
it "has locked row" do
table_name = row.class.table_name
ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+FOR UPDATE/m
ids_regex = /SELECT.*FROM.*#{table_name}.*"#{table_name}"."id" = #{row.id}.+#{lock_type}/m
expect(recorded_queries.log).to include a_string_matching 'SAVEPOINT'
expect(recorded_queries.log).to include a_string_matching ids_regex