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/raketasks/x509_signatures.md @aqualls
/doc/security/ @eread /doc/security/ @eread
/doc/ssh/index.md @eread /doc/ssh/index.md @eread
/doc/subscriptions/ @sselhorn /doc/subscriptions/ @fneill
/doc/system_hooks/system_hooks.md @kpaizee /doc/system_hooks/system_hooks.md @kpaizee
/doc/topics/authentication/index.md @eread /doc/topics/authentication/index.md @eread
/doc/topics/autodevops/customize.md @marcia /doc/topics/autodevops/customize.md @marcia
@ -791,5 +791,5 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab
/doc/user/snippets.md @aqualls /doc/user/snippets.md @aqualls
/doc/user/tasks.md @msedlakjakubowski /doc/user/tasks.md @msedlakjakubowski
/doc/user/todos.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 /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> <script>
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; 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 { createContentEditor } from '../services/create_content_editor';
import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorAlert from './content_editor_alert.vue';
import ContentEditorProvider from './content_editor_provider.vue'; import ContentEditorProvider from './content_editor_provider.vue';
@ -55,17 +54,12 @@ export default {
extensions, extensions,
serializerConfig, serializerConfig,
}); });
},
this.contentEditor.on(LOADING_CONTENT_EVENT, this.displayLoadingIndicator); mounted() {
this.contentEditor.on(LOADING_SUCCESS_EVENT, this.hideLoadingIndicator);
this.contentEditor.on(LOADING_ERROR_EVENT, this.hideLoadingIndicator);
this.$emit('initialized', this.contentEditor); this.$emit('initialized', this.contentEditor);
}, },
beforeDestroy() { beforeDestroy() {
this.contentEditor.dispose(); 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: { methods: {
displayLoadingIndicator() { displayLoadingIndicator() {
@ -91,7 +85,14 @@ export default {
<template> <template>
<content-editor-provider :content-editor="contentEditor"> <content-editor-provider :content-editor="contentEditor">
<div> <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 /> <content-editor-alert />
<div <div
data-testid="content-editor" data-testid="content-editor"

View File

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

View File

@ -1,5 +1,11 @@
<script> <script>
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import {
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
ALERT_EVENT,
} from '../constants';
export const tiptapToComponentMap = { export const tiptapToComponentMap = {
update: 'docUpdate', update: 'docUpdate',
@ -7,30 +13,48 @@ export const tiptapToComponentMap = {
transaction: 'transaction', transaction: 'transaction',
focus: 'focus', focus: 'focus',
blur: 'blur', blur: 'blur',
alert: 'alert',
}; };
export const eventHubEvents = [
ALERT_EVENT,
LOADING_CONTENT_EVENT,
LOADING_SUCCESS_EVENT,
LOADING_ERROR_EVENT,
];
const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName]; const getComponentEventName = (tiptapEventName) => tiptapToComponentMap[tiptapEventName];
export default { export default {
inject: ['tiptapEditor'], inject: ['tiptapEditor', 'eventHub'],
created() { created() {
this.disposables = []; this.disposables = [];
Object.keys(tiptapToComponentMap).forEach((tiptapEvent) => { 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.tiptapEditor?.on(tiptapEvent, eventHandler);
this.disposables.push(() => this.tiptapEditor?.off(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() { beforeDestroy() {
this.disposables.forEach((dispose) => dispose()); this.disposables.forEach((dispose) => dispose());
}, },
methods: { methods: {
handleTipTapEvent(tiptapEvent, params) { bubbleEvent(eventHubEvent, params) {
this.$emit(getComponentEventName(tiptapEvent), params); this.$emit(eventHubEvent, params);
}, },
}, },
render() { 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_SUCCESS_EVENT = 'loadingSuccess';
export const LOADING_ERROR_EVENT = 'loadingError'; export const LOADING_ERROR_EVENT = 'loadingError';
export const ALERT_EVENT = 'alert';
export const PARSE_HTML_PRIORITY_LOWEST = 1; export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50; export const PARSE_HTML_PRIORITY_DEFAULT = 50;
@ -56,3 +57,4 @@ export const EXTENSION_PRIORITY_LOWER = 75;
* https://tiptap.dev/guide/custom-extensions/#priority * https://tiptap.dev/guide/custom-extensions/#priority
*/ */
export const EXTENSION_PRIORITY_DEFAULT = 100; export const EXTENSION_PRIORITY_DEFAULT = 100;
export const EXTENSION_PRIORITY_HIGHEST = 200;

View File

@ -9,15 +9,22 @@ export default Extension.create({
return { return {
uploadsPath: null, uploadsPath: null,
renderMarkdown: null, renderMarkdown: null,
eventHub: null,
}; };
}, },
addCommands() { addCommands() {
return { return {
uploadAttachment: ({ file }) => () => { 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'), key: new PluginKey('attachment'),
props: { props: {
handlePaste: (_, event) => { handlePaste: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options; const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({ return handleFileEvent({
editor, editor,
file: event.clipboardData.files[0], file: event.clipboardData.files[0],
uploadsPath, uploadsPath,
renderMarkdown, renderMarkdown,
eventHub,
}); });
}, },
handleDrop: (_, event) => { handleDrop: (_, event) => {
const { uploadsPath, renderMarkdown } = this.options; const { uploadsPath, renderMarkdown, eventHub } = this.options;
return handleFileEvent({ return handleFileEvent({
editor, editor,
file: event.dataTransfer.files[0], file: event.dataTransfer.files[0],
uploadsPath, uploadsPath,
renderMarkdown, 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'; import { LOADING_CONTENT_EVENT, LOADING_SUCCESS_EVENT, LOADING_ERROR_EVENT } from '../constants';
/* eslint-disable no-underscore-dangle */ /* eslint-disable no-underscore-dangle */
export class ContentEditor { export class ContentEditor {
constructor({ tiptapEditor, serializer }) { constructor({ tiptapEditor, serializer, eventHub }) {
this._tiptapEditor = tiptapEditor; this._tiptapEditor = tiptapEditor;
this._serializer = serializer; this._serializer = serializer;
this._eventHub = eventHubFactory(); this._eventHub = eventHub;
} }
get tiptapEditor() { get tiptapEditor() {
return this._tiptapEditor; return this._tiptapEditor;
} }
get eventHub() {
return this._eventHub;
}
get empty() { get empty() {
const doc = this.tiptapEditor?.state.doc; const doc = this.tiptapEditor?.state.doc;
@ -23,39 +26,23 @@ export class ContentEditor {
this.tiptapEditor.destroy(); 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() { disposeAllEvents() {
this._eventHub.dispose(); this._eventHub.dispose();
} }
async setSerializedContent(serializedContent) { async setSerializedContent(serializedContent) {
const { _tiptapEditor: editor, _serializer: serializer } = this; const { _tiptapEditor: editor, _serializer: serializer, _eventHub: eventHub } = this;
try { try {
this._eventHub.$emit(LOADING_CONTENT_EVENT); eventHub.$emit(LOADING_CONTENT_EVENT);
const document = await serializer.deserialize({ const document = await serializer.deserialize({
schema: editor.schema, schema: editor.schema,
content: serializedContent, content: serializedContent,
}); });
editor.commands.setContent(document); editor.commands.setContent(document);
this._eventHub.$emit(LOADING_SUCCESS_EVENT); eventHub.$emit(LOADING_SUCCESS_EVENT);
} catch (e) { } catch (e) {
this._eventHub.$emit(LOADING_ERROR_EVENT, e); eventHub.$emit(LOADING_ERROR_EVENT, e);
throw e; throw e;
} }
} }

View File

@ -1,5 +1,6 @@
import { Editor } from '@tiptap/vue-2'; import { Editor } from '@tiptap/vue-2';
import { isFunction } from 'lodash'; import { isFunction } from 'lodash';
import eventHubFactory from '~/helpers/event_hub_factory';
import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants';
import Attachment from '../extensions/attachment'; import Attachment from '../extensions/attachment';
import Audio from '../extensions/audio'; import Audio from '../extensions/audio';
@ -78,8 +79,10 @@ export const createContentEditor = ({
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
} }
const eventHub = eventHubFactory();
const builtInContentEditorExtensions = [ const builtInContentEditorExtensions = [
Attachment.configure({ uploadsPath, renderMarkdown }), Attachment.configure({ uploadsPath, renderMarkdown, eventHub }),
Audio, Audio,
Blockquote, Blockquote,
Bold, Bold,
@ -137,5 +140,5 @@ export const createContentEditor = ({
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions }); const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ render: renderMarkdown, serializerConfig }); 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); return extractAttachmentLinkUrl(rendered);
}; };
const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => { const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
const encodedSrc = await readFileAsDataURL(file); const encodedSrc = await readFileAsDataURL(file);
const { view } = editor; const { view } = editor;
@ -72,14 +72,14 @@ const uploadImage = async ({ editor, file, uploadsPath, renderMarkdown }) => {
); );
} catch (e) { } catch (e) {
editor.commands.deleteRange({ from: position, to: position + 1 }); 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.'), message: __('An error occurred while uploading the image. Please try again.'),
variant: 'danger', variant: 'danger',
}); });
} }
}; };
const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) => { const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
await Promise.resolve(); await Promise.resolve();
const { view } = editor; const { view } = editor;
@ -103,23 +103,23 @@ const uploadAttachment = async ({ editor, file, uploadsPath, renderMarkdown }) =
); );
} catch (e) { } catch (e) {
editor.commands.deleteRange({ from, to: from + 1 }); editor.commands.deleteRange({ from, to: from + 1 });
editor.emit('alert', { eventHub.$emit('alert', {
message: __('An error occurred while uploading the file. Please try again.'), message: __('An error occurred while uploading the file. Please try again.'),
variant: 'danger', variant: 'danger',
}); });
} }
}; };
export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown }) => { export const handleFileEvent = ({ editor, file, uploadsPath, renderMarkdown, eventHub }) => {
if (!file) return false; if (!file) return false;
if (acceptedMimes.image.includes(file?.type)) { if (acceptedMimes.image.includes(file?.type)) {
uploadImage({ editor, file, uploadsPath, renderMarkdown }); uploadImage({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true; return true;
} }
uploadAttachment({ editor, file, uploadsPath, renderMarkdown }); uploadAttachment({ editor, file, uploadsPath, renderMarkdown, eventHub });
return true; return true;
}; };

View File

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

View File

@ -5,7 +5,6 @@ import { __, s__, sprintf } from '~/locale';
export default { export default {
i18n: { i18n: {
eraseLogButtonLabel: s__('Job|Erase job log and artifacts'),
scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), scrollToBottomButtonLabel: s__('Job|Scroll to bottom'),
scrollToTopButtonLabel: s__('Job|Scroll to top'), scrollToTopButtonLabel: s__('Job|Scroll to top'),
showRawButtonLabel: s__('Job|Show complete raw'), showRawButtonLabel: s__('Job|Show complete raw'),
@ -18,11 +17,6 @@ export default {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
props: { props: {
erasePath: {
type: String,
required: false,
default: null,
},
size: { size: {
type: Number, type: Number,
required: true, required: true,
@ -97,20 +91,6 @@ export default {
data-testid="job-raw-link-controller" data-testid="job-raw-link-controller"
icon="doc-text" 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 --> <!-- eo links -->
<!-- scroll buttons --> <!-- scroll buttons -->

View File

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

View File

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

View File

@ -1,12 +1,9 @@
import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior'; import initDeprecatedRemoveRowBehavior from '~/behaviors/deprecated_remove_row_behavior';
import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner';
import BranchSortDropdown from '~/branches/branch_sort_dropdown'; import BranchSortDropdown from '~/branches/branch_sort_dropdown';
import initDiverganceGraph from '~/branches/divergence_graph'; import initDiverganceGraph from '~/branches/divergence_graph';
import initDeleteBranchButton from '~/branches/init_delete_branch_button'; import initDeleteBranchButton from '~/branches/init_delete_branch_button';
import initDeleteBranchModal from '~/branches/init_delete_branch_modal'; import initDeleteBranchModal from '~/branches/init_delete_branch_modal';
AjaxLoadingSpinner.init();
const { divergingCountsEndpoint, defaultBranch } = document.querySelector( const { divergingCountsEndpoint, defaultBranch } = document.querySelector(
'.js-branch-list', '.js-branch-list',
).dataset; ).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 # ActiveRecord. https://github.com/rails/rails/issues/13496
# Ideally it would be: # Ideally it would be:
# `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')` # `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
sql = """ sql = <<-SQL
UPDATE namespaces UPDATE namespaces
SET traversal_ids = cte.traversal_ids SET traversal_ids = cte.traversal_ids
FROM (#{recursive_traversal_ids}) as cte FROM (#{recursive_traversal_ids}) as cte
WHERE namespaces.id = cte.id WHERE namespaces.id = cte.id
AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
""" SQL
Namespace.transaction do 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) Namespace.connection.exec_query(sql)
end end
rescue ActiveRecord::Deadlocked rescue ActiveRecord::Deadlocked

View File

@ -60,7 +60,7 @@ module ErrorTracking
end end
def actor def actor
return event['transaction'] if event['transaction'] return event['transaction'] if event['transaction'].present?
# Some SDKs do not have a transaction attribute. # Some SDKs do not have a transaction attribute.
# So we build it by combining function name and module name from # 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 $ vault policy write myproject-staging - <<EOF
# Policy name: myproject-staging # Policy name: myproject-staging
# #
# Read-only permission on 'secret/data/myproject/staging/*' path # Read-only permission on 'secret/myproject/staging/*' path
path "secret/data/myproject/staging/*" { path "secret/myproject/staging/*" {
capabilities = [ "read" ] capabilities = [ "read" ]
} }
EOF EOF
@ -131,8 +131,8 @@ Success! Uploaded policy: myproject-staging
$ vault policy write myproject-production - <<EOF $ vault policy write myproject-production - <<EOF
# Policy name: myproject-production # Policy name: myproject-production
# #
# Read-only permission on 'secret/data/myproject/production/*' path # Read-only permission on 'secret/myproject/production/*' path
path "secret/data/myproject/production/*" { path "secret/myproject/production/*" {
capabilities = [ "read" ] capabilities = [ "read" ]
} }
EOF EOF

View File

@ -140,7 +140,7 @@ to its **Pipelines** tab.
![Pipelines index page](img/pipelines_index_v13_0.png) ![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, 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). 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), [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. 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. to execute that particular job.
For example, your pipeline can start automatically, but require a manual action to 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. > [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. Multiple manual actions in a single stage can be started at the same time using the "Play all manual"
After you click this button, each individual manual action is triggered and refreshed After you select this action, each individual manual action is triggered and refreshed
to an updated status. to an updated status.
This functionality is only available: This functionality is only available:
@ -283,9 +283,9 @@ pipelines.
Users with the Owner role for a project can delete a pipeline 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** 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: WARNING:
Deleting a pipeline expires all pipeline caches, and deletes all related objects, 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 **Runners** marked as **protected** can run jobs only on protected
branches, preventing untrusted code from executing on the protected runner and branches, preventing untrusted code from executing on the protected runner and
preserving deployment keys and other credentials from being unintentionally 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. runners do not use regular runners, they must be tagged accordingly.
### How pipeline duration is calculated ### How pipeline duration is calculated
@ -434,7 +434,7 @@ fix it.
Pipeline mini graphs only display jobs by stage. 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 | | 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: 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: - Create explicit downtime by stopping all Gitaly services before backing up the repositories:
```shell ```shell
@ -1354,15 +1354,13 @@ To prepare the new server:
```shell ```shell
sudo rm -f /var/opt/gitlab/redis/dump.rdb sudo rm -f /var/opt/gitlab/redis/dump.rdb
sudo chown <your-linux-username> /var/opt/gitlab/redis sudo chown <your-linux-username> /var/opt/gitlab/redis /var/opt/gitlab/backups
sudo mkdir /var/opt/gitlab/backups
sudo chown <your-linux-username> /var/opt/gitlab/backups
``` ```
### Prepare and transfer content from the old server ### 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. 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. if supported by your GitLab edition.
1. Block new CI/CD jobs from starting: 1. Block new CI/CD jobs from starting:
1. Edit `/etc/gitlab/gitlab.rb`, and set the following: 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** 1. While still under the Sidekiq dashboard, select **Cron** and then **Enable All**
to re-enable periodic background jobs. 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. 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. 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. 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. 1. Update your DNS or load balancer to point at the new server.

View File

@ -1,34 +1,9 @@
--- ---
stage: Enablement redirect_to: 'index.md'
group: Distribution remove_date: '2022-05-24'
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
--- ---
# Namespaces **(FREE SELF)** This document was moved to [another location](index.md).
This Rake task enables [namespaces](../user/group/index.md#namespaces) for projects. <!-- 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 -->
## 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
```

View File

@ -12,12 +12,49 @@ instance entirely offline.
## Installation ## Installation
NOTE: NOTE:
This guide assumes the server is Ubuntu 18.04. Instructions for other servers may vary. 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`, which you should replace with your This guide also assumes the server host resolves as `my-host.internal`, which you should replace with your
server's name. 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 <i class="fa fa-youtube-play youtube" aria-hidden="true"></i>
guide](https://about.gitlab.com/install/#ubuntu), but make sure to specify an `http` 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 URL for the `EXTERNAL_URL` installation step. Once installed, we can manually
configure the SSL ourselves. 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 to the server's IP address. This better ensures a stable target for our certs' CN
and makes long-term resolution simpler. and makes long-term resolution simpler.
The following example for Ubuntu specifies the `EXTERNAL_URL` using HTTP and installs the GitLab package:
```shell ```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 ## Enabling SSL
@ -38,7 +77,7 @@ Follow these steps to enable SSL for your fresh instance. Note that these steps
```ruby ```ruby
# Update external_url from "http" to "https" # 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 # Set Let's Encrypt to false
letsencrypt['enable'] = 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 When you [add a linked issue](#add-a-linked-issue), you can show that it **blocks** or
**is blocked by** another issue. **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 class Receiver
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
RECEIVED_HEADER_REGEX = /for\s+\<(.+)\>/.freeze
def initialize(raw) def initialize(raw)
@raw = raw @raw = raw
end end
@ -37,6 +39,8 @@ module Gitlab
delivered_to: delivered_to.map(&:value), delivered_to: delivered_to.map(&:value),
envelope_to: envelope_to.map(&:value), envelope_to: envelope_to.map(&:value),
x_envelope_to: x_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: { meta: {
client_id: "email/#{mail.from.first}", client_id: "email/#{mail.from.first}",
project: handler&.project&.full_path project: handler&.project&.full_path
@ -82,7 +86,8 @@ module Gitlab
find_key_from_references || find_key_from_references ||
find_key_from_delivered_to_header || find_key_from_delivered_to_header ||
find_key_from_envelope_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 end
def ensure_references_array(references) def ensure_references_array(references)
@ -117,6 +122,10 @@ module Gitlab
Array(mail[:x_envelope_to]) Array(mail[:x_envelope_to])
end end
def received
Array(mail[:received])
end
def find_key_from_delivered_to_header def find_key_from_delivered_to_header
delivered_to.find do |header| delivered_to.find do |header|
key = email_class.key_from_address(header.value) key = email_class.key_from_address(header.value)
@ -138,6 +147,21 @@ module Gitlab
end end
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! def ignore_auto_reply!
if auto_submitted? || auto_replied? if auto_submitted? || auto_replied?
raise AutoGeneratedEmailError raise AutoGeneratedEmailError

View File

@ -4738,9 +4738,6 @@ msgstr ""
msgid "Are you sure you want to discard your changes?" msgid "Are you sure you want to discard your changes?"
msgstr "" msgstr ""
msgid "Are you sure you want to erase this build?"
msgstr ""
msgid "Are you sure you want to import %d repository?" msgid "Are you sure you want to import %d repository?"
msgid_plural "Are you sure you want to import %d repositories?" msgid_plural "Are you sure you want to import %d repositories?"
msgstr[0] "" 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." 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 "" msgstr ""
msgid "Job|Are you sure you want to erase this job log and artifacts?"
msgstr ""
msgid "Job|Browse" msgid "Job|Browse"
msgstr "" msgstr ""
msgid "Job|Cancel"
msgstr ""
msgid "Job|Complete Raw" msgid "Job|Complete Raw"
msgstr "" msgstr ""
@ -21061,6 +21064,9 @@ msgstr ""
msgid "Job|Pipeline" msgid "Job|Pipeline"
msgstr "" msgstr ""
msgid "Job|Retry"
msgstr ""
msgid "Job|Scroll to bottom" msgid "Job|Scroll to bottom"
msgstr "" msgstr ""
@ -26813,12 +26819,21 @@ msgstr ""
msgid "PipelineWizardDefaultCommitMessage|Update %{filename}" msgid "PipelineWizardDefaultCommitMessage|Update %{filename}"
msgstr "" msgstr ""
msgid "PipelineWizardInputValidation|At least one entry is required"
msgstr ""
msgid "PipelineWizardInputValidation|This field is required" msgid "PipelineWizardInputValidation|This field is required"
msgstr "" msgstr ""
msgid "PipelineWizardInputValidation|This value is not valid" msgid "PipelineWizardInputValidation|This value is not valid"
msgstr "" msgstr ""
msgid "PipelineWizardListWidget|add another step"
msgstr ""
msgid "PipelineWizardListWidget|remove step"
msgstr ""
msgid "PipelineWizard|Commit" msgid "PipelineWizard|Commit"
msgstr "" msgstr ""

View File

@ -313,7 +313,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
context 'job is cancelable' do context 'job is cancelable' do
it 'shows cancel button' do it 'shows cancel button' do
click_link 'Cancel' find('[data-testid="cancel-button"]').click
expect(page.current_path).to eq(job_url) expect(page.current_path).to eq(job_url)
end end
@ -1031,7 +1031,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
end end
it 'loads the page and shows all needed controls' do 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 end
end end
@ -1049,7 +1049,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
it 'shows the right status and buttons' do it 'shows the right status and buttons' do
page.within('aside.right-sidebar') 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 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> 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 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 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 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 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 { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue'; import ContentEditorAlert from '~/content_editor/components/content_editor_alert.vue';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.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', () => { describe('content_editor/components/content_editor_alert', () => {
let wrapper; let wrapper;
let tiptapEditor; let tiptapEditor;
let eventHub;
const findErrorAlert = () => wrapper.findComponent(GlAlert); const findErrorAlert = () => wrapper.findComponent(GlAlert);
const createWrapper = async () => { const createWrapper = async () => {
tiptapEditor = createTestEditor(); tiptapEditor = createTestEditor();
eventHub = eventHubFactory();
wrapper = shallowMountExtended(ContentEditorAlert, { wrapper = shallowMountExtended(ContentEditorAlert, {
provide: { provide: {
tiptapEditor, tiptapEditor,
eventHub,
}, },
stubs: { stubs: {
EditorStateObserver, EditorStateObserver,
@ -37,7 +42,9 @@ describe('content_editor/components/content_editor_alert', () => {
async ({ message, variant }) => { async ({ message, variant }) => {
createWrapper(); createWrapper();
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message, variant } }); eventHub.$emit(ALERT_EVENT, { message, variant });
await nextTick();
expect(findErrorAlert().text()).toBe(message); expect(findErrorAlert().text()).toBe(message);
expect(findErrorAlert().attributes().variant).toBe(variant); expect(findErrorAlert().attributes().variant).toBe(variant);
@ -48,11 +55,9 @@ describe('content_editor/components/content_editor_alert', () => {
const message = 'error message'; const message = 'error message';
createWrapper(); createWrapper();
eventHub.$emit(ALERT_EVENT, { message });
await emitEditorEvent({ tiptapEditor, event: 'alert', params: { message } }); await nextTick();
findErrorAlert().vm.$emit('dismiss'); findErrorAlert().vm.$emit('dismiss');
await nextTick(); await nextTick();
expect(findErrorAlert().exists()).toBe(false); expect(findErrorAlert().exists()).toBe(false);

View File

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

View File

@ -3,6 +3,13 @@ import { each } from 'lodash';
import EditorStateObserver, { import EditorStateObserver, {
tiptapToComponentMap, tiptapToComponentMap,
} from '~/content_editor/components/editor_state_observer.vue'; } 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'; import { createTestEditor } from '../test_utils';
describe('content_editor/components/editor_state_observer', () => { describe('content_editor/components/editor_state_observer', () => {
@ -11,19 +18,29 @@ describe('content_editor/components/editor_state_observer', () => {
let onDocUpdateListener; let onDocUpdateListener;
let onSelectionUpdateListener; let onSelectionUpdateListener;
let onTransactionListener; let onTransactionListener;
let onLoadingContentListener;
let onLoadingSuccessListener;
let onLoadingErrorListener;
let onAlertListener;
let eventHub;
const buildEditor = () => { const buildEditor = () => {
tiptapEditor = createTestEditor(); tiptapEditor = createTestEditor();
eventHub = eventHubFactory();
jest.spyOn(tiptapEditor, 'on'); jest.spyOn(tiptapEditor, 'on');
}; };
const buildWrapper = () => { const buildWrapper = () => {
wrapper = shallowMount(EditorStateObserver, { wrapper = shallowMount(EditorStateObserver, {
provide: { tiptapEditor }, provide: { tiptapEditor, eventHub },
listeners: { listeners: {
docUpdate: onDocUpdateListener, docUpdate: onDocUpdateListener,
selectionUpdate: onSelectionUpdateListener, selectionUpdate: onSelectionUpdateListener,
transaction: onTransactionListener, 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(); onDocUpdateListener = jest.fn();
onSelectionUpdateListener = jest.fn(); onSelectionUpdateListener = jest.fn();
onTransactionListener = jest.fn(); onTransactionListener = jest.fn();
onAlertListener = jest.fn();
onLoadingSuccessListener = jest.fn();
onLoadingContentListener = jest.fn();
onLoadingErrorListener = jest.fn();
buildEditor(); buildEditor();
buildWrapper();
}); });
afterEach(() => { afterEach(() => {
@ -44,6 +64,8 @@ describe('content_editor/components/editor_state_observer', () => {
it('emits update, selectionUpdate, and transaction events', () => { it('emits update, selectionUpdate, and transaction events', () => {
const content = '<p>My paragraph</p>'; const content = '<p>My paragraph</p>';
buildWrapper();
tiptapEditor.commands.insertContent(content); tiptapEditor.commands.insertContent(content);
expect(onDocUpdateListener).toHaveBeenCalledWith( 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', () => { describe('when component is destroyed', () => {
it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => { it('removes onTiptapDocUpdate and onTiptapSelectionUpdate hooks', () => {
jest.spyOn(tiptapEditor, 'off'); jest.spyOn(tiptapEditor, 'off');
buildWrapper();
wrapper.destroy(); wrapper.destroy();
each(tiptapToComponentMap, (_, tiptapEvent) => { 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 { shallowMount } from '@vue/test-utils';
import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue'; import EditorStateObserver from '~/content_editor/components/editor_state_observer.vue';
import ToolbarButton from '~/content_editor/components/toolbar_button.vue'; import ToolbarButton from '~/content_editor/components/toolbar_button.vue';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_button', () => { describe('content_editor/components/toolbar_button', () => {
@ -25,6 +26,7 @@ describe('content_editor/components/toolbar_button', () => {
}, },
provide: { provide: {
tiptapEditor, tiptapEditor,
eventHub: eventHubFactory(),
}, },
propsData: { propsData: {
contentType: CONTENT_TYPE, contentType: CONTENT_TYPE,

View File

@ -1,6 +1,7 @@
import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui'; import { GlDropdown, GlButton, GlFormInputGroup } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import ToolbarLinkButton from '~/content_editor/components/toolbar_link_button.vue'; 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 Link from '~/content_editor/extensions/link';
import { hasSelection } from '~/content_editor/services/utils'; import { hasSelection } from '~/content_editor/services/utils';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
@ -15,6 +16,7 @@ describe('content_editor/components/toolbar_link_button', () => {
wrapper = mountExtended(ToolbarLinkButton, { wrapper = mountExtended(ToolbarLinkButton, {
provide: { provide: {
tiptapEditor: editor, 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 ToolbarTextStyleDropdown from '~/content_editor/components/toolbar_text_style_dropdown.vue';
import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants'; import { TEXT_STYLE_DROPDOWN_ITEMS } from '~/content_editor/constants';
import Heading from '~/content_editor/extensions/heading'; import Heading from '~/content_editor/extensions/heading';
import eventHubFactory from '~/helpers/event_hub_factory';
import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils'; import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_utils';
describe('content_editor/components/toolbar_text_style_dropdown', () => { describe('content_editor/components/toolbar_text_style_dropdown', () => {
@ -27,6 +28,7 @@ describe('content_editor/components/toolbar_text_style_dropdown', () => {
}, },
provide: { provide: {
tiptapEditor, tiptapEditor,
eventHub: eventHubFactory(),
}, },
propsData: { propsData: {
...propsData, ...propsData,

View File

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

View File

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

View File

@ -8,7 +8,6 @@ describe('Job log controllers', () => {
afterEach(() => { afterEach(() => {
if (wrapper?.destroy) { if (wrapper?.destroy) {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
} }
}); });
@ -34,7 +33,6 @@ describe('Job log controllers', () => {
const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]'); const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]');
const findRawLink = () => wrapper.find('[data-testid="raw-link"]'); const findRawLink = () => wrapper.find('[data-testid="raw-link"]');
const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); 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 findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]');
const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]');
@ -76,28 +74,6 @@ describe('Job log controllers', () => {
expect(findRawLinkController().exists()).toBe(false); 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', () => { describe('scroll buttons', () => {

View File

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

View File

@ -21,25 +21,54 @@ describe('Sidebar details block', () => {
const findNewIssueButton = () => wrapper.findByTestId('job-new-issue'); const findNewIssueButton = () => wrapper.findByTestId('job-new-issue');
const findRetryButton = () => wrapper.find(JobRetryButton); const findRetryButton = () => wrapper.find(JobRetryButton);
const findTerminalLink = () => wrapper.findByTestId('terminal-link'); const findTerminalLink = () => wrapper.findByTestId('terminal-link');
const findEraseLink = () => wrapper.findByTestId('job-log-erase-link');
const createWrapper = ({ props = {} } = {}) => { const createWrapper = (props) => {
store = createStore(); store = createStore();
store.state.job = job; store.state.job = job;
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(Sidebar, { shallowMount(Sidebar, {
...props, propsData: {
...props,
},
store, store,
}), }),
); );
}; };
afterEach(() => { afterEach(() => {
if (wrapper) { wrapper.destroy();
wrapper.destroy(); });
wrapper = null;
} 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', () => { describe('when there is no retry path retry', () => {
@ -86,7 +115,7 @@ describe('Sidebar details block', () => {
}); });
it('should render link to cancel job', () => { 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); 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 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]).to include(client_id: 'email/jake@example.com', project: project.full_path)
expect(metadata[meta_key]).to eq(meta_value) expect(metadata[meta_key]).to eq(meta_value)
end end
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 context 'when the email contains a valid email address in a header' do
before do before do
stub_incoming_email_setting(enabled: true, address: "incoming+%{key}@appmail.example.com") 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' it_behaves_like 'successful receive'
end end
end
shared_examples 'failed receive' do context 'when all other headers are missing' do
it 'adds metric event' do let(:email_raw) { fixture_file('emails/missing_delivered_to_header.eml') }
expect(::Gitlab::Metrics::BackgroundTransaction).to receive(:current).and_return(metric_transaction) let(:meta_key) { :received_recipients }
expect(metric_transaction).to receive(:add_event).with('email_receiver_error', { error: expected_error.name }) 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
end end

View File

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

View File

@ -68,11 +68,24 @@ RSpec.describe Namespace::TraversalHierarchy, type: :model do
end end
end end
it_behaves_like 'locked row' do it_behaves_like 'locked row', 'FOR UPDATE' do
let(:recorded_queries) { ActiveRecord::QueryRecorder.new } let(:recorded_queries) { ActiveRecord::QueryRecorder.new }
let(:row) { root } let(:row) { root }
before do 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 } recorded_queries.record { subject }
end end
end end

View File

@ -171,6 +171,12 @@ RSpec.describe API::ErrorTracking::Collector do
it_behaves_like 'successful request' it_behaves_like 'successful request'
end 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 context 'sentry_key as param and empty headers' do
let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" } let(:url) { "/error_tracking/collector/api/#{project.id}/store?sentry_key=#{sentry_key}" }
let(:headers) { {} } let(:headers) { {} }

View File

@ -51,25 +51,30 @@ RSpec.describe ErrorTracking::CollectErrorService do
end end
end end
context 'unusual payload' do context 'with unusual payload' do
let(:modified_event) { parsed_event } 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 it 'builds actor from stacktrace' do
modified_event.delete('transaction') modified_event.delete('transaction')
event = described_class.new(project, nil, event: modified_event).execute
expect(event.error.actor).to eq 'find()' expect(event.error.actor).to eq 'find()'
end end
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 it 'parses timestamp' do
modified_event['timestamp'] = '1631015580.50' 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' expect(event.occurred_at).to eq '2021-09-07T11:53:00.5'
end end
end end

View File

@ -4,10 +4,10 @@
# Ensure a transaction also occurred. # Ensure a transaction also occurred.
# Be careful! This form of spec is not foolproof, but better than nothing. # 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 it "has locked row" do
table_name = row.class.table_name 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 'SAVEPOINT'
expect(recorded_queries.log).to include a_string_matching ids_regex expect(recorded_queries.log).to include a_string_matching ids_regex