Commit 5afd8575 authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 8bda404e
const maxColumnWidth = (rows, columnIndex) => Math.max(...rows.map(row => row[columnIndex].length));
export default class PasteMarkdownTable { export default class PasteMarkdownTable {
constructor(clipboardData) { constructor(clipboardData) {
this.data = clipboardData; this.data = clipboardData;
this.columnWidths = [];
this.rows = [];
this.tableFound = this.parseTable();
}
isTable() {
return this.tableFound;
} }
static maxColumnWidth(rows, columnIndex) { convertToTableMarkdown() {
return Math.max.apply(null, rows.map(row => row[columnIndex].length)); this.calculateColumnWidths();
const markdownRows = this.rows.map(
row =>
// | Name | Title | Email Address |
// |--------------|-------|----------------|
// | Jane Atler | CEO | jane@acme.com |
// | John Doherty | CTO | john@acme.com |
// | Sally Smith | CFO | sally@acme.com |
`| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`,
);
// Insert a header break (e.g. -----) to the second row
markdownRows.splice(1, 0, this.generateHeaderBreak());
return markdownRows.join('\n');
} }
// Private methods below
// To determine whether the cut data is a table, the following criteria // To determine whether the cut data is a table, the following criteria
// must be satisfied with the clipboard data: // must be satisfied with the clipboard data:
// //
// 1. MIME types "text/plain" and "text/html" exist // 1. MIME types "text/plain" and "text/html" exist
// 2. The "text/html" data must have a single <table> element // 2. The "text/html" data must have a single <table> element
static isTable(data) { // 3. The number of rows in the "text/plain" data matches that of the "text/html" data
const types = new Set(data.types); // 4. The max number of columns in "text/plain" matches that of the "text/html" data
parseTable() {
if (!types.has('text/html') || !types.has('text/plain')) { if (!this.data.types.includes('text/html') || !this.data.types.includes('text/plain')) {
return false; return false;
} }
const htmlData = data.getData('text/html'); const htmlData = this.data.getData('text/html');
const doc = new DOMParser().parseFromString(htmlData, 'text/html'); this.doc = new DOMParser().parseFromString(htmlData, 'text/html');
const tables = this.doc.querySelectorAll('table');
// We're only looking for exactly one table. If there happens to be // We're only looking for exactly one table. If there happens to be
// multiple tables, it's possible an application copied data into // multiple tables, it's possible an application copied data into
// the clipboard that is not related to a simple table. It may also be // the clipboard that is not related to a simple table. It may also be
// complicated converting multiple tables into Markdown. // complicated converting multiple tables into Markdown.
if (doc.querySelectorAll('table').length === 1) { if (tables.length !== 1) {
return true; return false;
} }
return false;
}
convertToTableMarkdown() {
const text = this.data.getData('text/plain').trim(); const text = this.data.getData('text/plain').trim();
this.rows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g).map(row => row.split('\t')); const splitRows = text.split(/[\n\u0085\u2028\u2029]|\r\n?/g);
this.normalizeRows();
this.calculateColumnWidths();
const markdownRows = this.rows.map( // Now check that the number of rows matches between HTML and text
row => if (this.doc.querySelectorAll('tr').length !== splitRows.length) {
// | Name | Title | Email Address | return false;
// |--------------|-------|----------------| }
// | Jane Atler | CEO | jane@acme.com |
// | John Doherty | CTO | john@acme.com |
// | Sally Smith | CFO | sally@acme.com |
`| ${row.map((column, index) => this.formatColumn(column, index)).join(' | ')} |`,
);
// Insert a header break (e.g. -----) to the second row this.rows = splitRows.map(row => row.split('\t'));
markdownRows.splice(1, 0, this.generateHeaderBreak()); this.normalizeRows();
return markdownRows.join('\n'); // Check that the max number of columns in the HTML matches the number of
// columns in the text. GitHub, for example, copies a line number and the
// line itself into the HTML data.
if (!this.columnCountsMatch()) {
return false;
}
return true;
} }
// Ensure each row has the same number of columns // Ensure each row has the same number of columns
...@@ -69,10 +92,21 @@ export default class PasteMarkdownTable { ...@@ -69,10 +92,21 @@ export default class PasteMarkdownTable {
calculateColumnWidths() { calculateColumnWidths() {
this.columnWidths = this.rows[0].map((_column, columnIndex) => this.columnWidths = this.rows[0].map((_column, columnIndex) =>
PasteMarkdownTable.maxColumnWidth(this.rows, columnIndex), maxColumnWidth(this.rows, columnIndex),
); );
} }
columnCountsMatch() {
const textColumnCount = this.rows[0].length;
let htmlColumnCount = 0;
this.doc.querySelectorAll('table tr').forEach(row => {
htmlColumnCount = Math.max(row.cells.length, htmlColumnCount);
});
return textColumnCount === htmlColumnCount;
}
formatColumn(column, index) { formatColumn(column, index) {
const spaces = Array(this.columnWidths[index] - column.length + 1).join(' '); const spaces = Array(this.columnWidths[index] - column.length + 1).join(' ');
return column + spaces; return column + spaces;
......
...@@ -176,11 +176,11 @@ export default function dropzoneInput(form) { ...@@ -176,11 +176,11 @@ export default function dropzoneInput(form) {
const pasteEvent = event.originalEvent; const pasteEvent = event.originalEvent;
const { clipboardData } = pasteEvent; const { clipboardData } = pasteEvent;
if (clipboardData && clipboardData.items) { if (clipboardData && clipboardData.items) {
const converter = new PasteMarkdownTable(clipboardData);
// Apple Numbers copies a table as an image, HTML, and text, so // Apple Numbers copies a table as an image, HTML, and text, so
// we need to check for the presence of a table first. // we need to check for the presence of a table first.
if (PasteMarkdownTable.isTable(clipboardData)) { if (converter.isTable()) {
event.preventDefault(); event.preventDefault();
const converter = new PasteMarkdownTable(clipboardData);
const text = converter.convertToTableMarkdown(); const text = converter.convertToTableMarkdown();
pasteText(text); pasteText(text);
} else { } else {
......
...@@ -22,9 +22,11 @@ import GraphGroup from './graph_group.vue'; ...@@ -22,9 +22,11 @@ import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
import GroupEmptyState from './group_empty_state.vue'; import GroupEmptyState from './group_empty_state.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event'; import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getTimeDiff, isValidDate, getAddMetricTrackingOptions } from '../utils'; import { getTimeDiff, getAddMetricTrackingOptions } from '../utils';
import { metricStates } from '../constants'; import { metricStates } from '../constants';
const defaultTimeDiff = getTimeDiff();
export default { export default {
components: { components: {
VueDraggable, VueDraggable,
...@@ -168,9 +170,10 @@ export default { ...@@ -168,9 +170,10 @@ export default {
return { return {
state: 'gettingStarted', state: 'gettingStarted',
formIsValid: null, formIsValid: null,
selectedTimeWindow: {}, startDate: getParameterValues('start')[0] || defaultTimeDiff.start,
isRearrangingPanels: false, endDate: getParameterValues('end')[0] || defaultTimeDiff.end,
hasValidDates: true, hasValidDates: true,
isRearrangingPanels: false,
}; };
}, },
computed: { computed: {
...@@ -228,24 +231,10 @@ export default { ...@@ -228,24 +231,10 @@ export default {
if (!this.hasMetrics) { if (!this.hasMetrics) {
this.setGettingStartedEmptyState(); this.setGettingStartedEmptyState();
} else { } else {
const defaultRange = getTimeDiff(); this.fetchData({
const start = getParameterValues('start')[0] || defaultRange.start; start: this.startDate,
const end = getParameterValues('end')[0] || defaultRange.end; end: this.endDate,
});
const range = {
start,
end,
};
this.selectedTimeWindow = range;
if (!isValidDate(start) || !isValidDate(end)) {
this.hasValidDates = false;
this.showInvalidDateError();
} else {
this.hasValidDates = true;
this.fetchData(range);
}
} }
}, },
methods: { methods: {
...@@ -267,9 +256,20 @@ export default { ...@@ -267,9 +256,20 @@ export default {
key, key,
}); });
}, },
showInvalidDateError() {
createFlash(s__('Metrics|Link contains an invalid time window.')); onDateTimePickerApply(params) {
redirectTo(mergeUrlParams(params, window.location.href));
},
onDateTimePickerInvalid() {
createFlash(
s__(
'Metrics|Link contains an invalid time window, please verify the link to see the requested time range.',
),
);
this.startDate = defaultTimeDiff.start;
this.endDate = defaultTimeDiff.end;
}, },
generateLink(group, title, yLabel) { generateLink(group, title, yLabel) {
const dashboard = this.currentDashboard || this.firstDashboard.path; const dashboard = this.currentDashboard || this.firstDashboard.path;
const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null);
...@@ -287,9 +287,6 @@ export default { ...@@ -287,9 +287,6 @@ export default {
submitCustomMetricsForm() { submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit(); this.$refs.customMetricsForm.submit();
}, },
onDateTimePickerApply(timeWindowUrlParams) {
return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href));
},
/** /**
* Return a single empty state for a group. * Return a single empty state for a group.
* *
...@@ -378,15 +375,16 @@ export default { ...@@ -378,15 +375,16 @@ export default {
</gl-form-group> </gl-form-group>
<gl-form-group <gl-form-group
v-if="hasValidDates"
:label="s__('Metrics|Show last')" :label="s__('Metrics|Show last')"
label-size="sm" label-size="sm"
label-for="monitor-time-window-dropdown" label-for="monitor-time-window-dropdown"
class="col-sm-6 col-md-6 col-lg-4" class="col-sm-6 col-md-6 col-lg-4"
> >
<date-time-picker <date-time-picker
:selected-time-window="selectedTimeWindow" :start="startDate"
@onApply="onDateTimePickerApply" :end="endDate"
@apply="onDateTimePickerApply"
@invalid="onDateTimePickerInvalid"
/> />
</gl-form-group> </gl-form-group>
</template> </template>
......
...@@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue'; ...@@ -5,14 +5,21 @@ import Icon from '~/vue_shared/components/icon.vue';
import DateTimePickerInput from './date_time_picker_input.vue'; import DateTimePickerInput from './date_time_picker_input.vue';
import { import {
getTimeDiff, getTimeDiff,
isValidDate,
getTimeWindow, getTimeWindow,
stringToISODate, stringToISODate,
ISODateToString, ISODateToString,
truncateZerosInDateTime, truncateZerosInDateTime,
isDateTimePickerInputValid, isDateTimePickerInputValid,
} from '~/monitoring/utils'; } from '~/monitoring/utils';
import { timeWindows } from '~/monitoring/constants'; import { timeWindows } from '~/monitoring/constants';
const events = {
apply: 'apply',
invalid: 'invalid',
};
export default { export default {
components: { components: {
Icon, Icon,
...@@ -23,77 +30,94 @@ export default { ...@@ -23,77 +30,94 @@ export default {
GlDropdownItem, GlDropdownItem,
}, },
props: { props: {
start: {
type: String,
required: true,
},
end: {
type: String,
required: true,
},
timeWindows: { timeWindows: {
type: Object, type: Object,
required: false, required: false,
default: () => timeWindows, default: () => timeWindows,
}, },
selectedTimeWindow: {
type: Object,
required: false,
default: () => {},
},
}, },
data() { data() {
return { return {
selectedTimeWindowText: '', startDate: this.start,
customTime: { endDate: this.end,
from: null,
to: null,
},
}; };
}, },
computed: { computed: {
applyEnabled() { startInputValid() {
return Boolean(this.inputState.from && this.inputState.to); return isValidDate(this.startDate);
}, },
inputState() { endInputValid() {
const { from, to } = this.customTime; return isValidDate(this.endDate);
return {
from: from && isDateTimePickerInputValid(from),
to: to && isDateTimePickerInputValid(to),
};
}, },
}, isValid() {
watch: { return this.startInputValid && this.endInputValid;
selectedTimeWindow() { },
this.verifyTimeRange();
startInput: {
get() {
return this.startInputValid ? this.formatDate(this.startDate) : this.startDate;
},
set(val) {
// Attempt to set a formatted date if possible
this.startDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
},
},
endInput: {
get() {
return this.endInputValid ? this.formatDate(this.endDate) : this.endDate;
},
set(val) {
// Attempt to set a formatted date if possible
this.endDate = isDateTimePickerInputValid(val) ? stringToISODate(val) : val;
},
},
timeWindowText() {
const timeWindow = getTimeWindow({ start: this.start, end: this.end });
if (timeWindow) {
return this.timeWindows[timeWindow];
} else if (isValidDate(this.start) && isValidDate(this.end)) {
return sprintf(s__('%{start} to %{end}'), {
start: this.formatDate(this.start),
end: this.formatDate(this.end),
});
}
return '';
}, },
}, },
mounted() { mounted() {
this.verifyTimeRange(); // Validate on mounted, and trigger an update if needed
if (!this.isValid) {
this.$emit(events.invalid);
}
}, },
methods: { methods: {
activeTimeWindow(key) { formatDate(date) {
return this.timeWindows[key] === this.selectedTimeWindowText; return truncateZerosInDateTime(ISODateToString(date));
}, },
setCustomTimeWindowParameter() { setTimeWindow(key) {
this.$emit('onApply', {
start: stringToISODate(this.customTime.from),
end: stringToISODate(this.customTime.to),
});
},
setTimeWindowParameter(key) {
const { start, end } = getTimeDiff(key); const { start, end } = getTimeDiff(key);
this.$emit('onApply', { this.startDate = start;
start, this.endDate = end;
end,
}); this.apply();
}, },
closeDropdown() { closeDropdown() {
this.$refs.dropdown.hide(); this.$refs.dropdown.hide();
}, },
verifyTimeRange() { apply() {
const range = getTimeWindow(this.selectedTimeWindow); this.$emit(events.apply, {
if (range) { start: this.startDate,
this.selectedTimeWindowText = this.timeWindows[range]; end: this.endDate,
} else { });
this.customTime = {
from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)),
to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)),
};
this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime);
}
}, },
}, },
}; };
...@@ -101,7 +125,7 @@ export default { ...@@ -101,7 +125,7 @@ export default {
<template> <template>
<gl-dropdown <gl-dropdown
ref="dropdown" ref="dropdown"
:text="selectedTimeWindowText" :text="timeWindowText"
menu-class="time-window-dropdown-menu" menu-class="time-window-dropdown-menu"
class="js-time-window-dropdown" class="js-time-window-dropdown"
> >
...@@ -113,24 +137,21 @@ export default { ...@@ -113,24 +137,21 @@ export default {
> >
<date-time-picker-input <date-time-picker-input
id="custom-time-from" id="custom-time-from"
v-model="customTime.from" v-model="startInput"
:label="__('From')" :label="__('From')"
:state="inputState.from" :state="startInputValid"
/> />
<date-time-picker-input <date-time-picker-input
id="custom-time-to" id="custom-time-to"
v-model="customTime.to" v-model="endInput"
:label="__('To')" :label="__('To')"
:state="inputState.to" :state="endInputValid"
/> />
<gl-form-group> <gl-form-group>
<gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button> <gl-button @click="closeDropdown">{{ __('Cancel') }}</gl-button>
<gl-button <gl-button variant="success" :disabled="!isValid" @click="apply()">
variant="success" {{ __('Apply') }}
:disabled="!applyEnabled" </gl-button>
@click="setCustomTimeWindowParameter"
>{{ __('Apply') }}</gl-button
>
</gl-form-group> </gl-form-group>
</gl-form-group> </gl-form-group>
<gl-form-group <gl-form-group
...@@ -142,14 +163,14 @@ export default { ...@@ -142,14 +163,14 @@ export default {
<gl-dropdown-item <gl-dropdown-item
v-for="(value, key) in timeWindows" v-for="(value, key) in timeWindows"
:key="key" :key="key"
:active="activeTimeWindow(key)" :active="value === timeWindowText"
active-class="active" active-class="active"
@click="setTimeWindowParameter(key)" @click="setTimeWindow(key)"
> >
<icon <icon
name="mobile-issue-close" name="mobile-issue-close"
class="align-bottom" class="align-bottom"
:class="{ invisible: !activeTimeWindow(key) }" :class="{ invisible: value !== timeWindowText }"
/> />
{{ value }} {{ value }}
</gl-dropdown-item> </gl-dropdown-item>
......
---
title: Add remaining project services to usage ping
merge_request: 21843
author:
type: added
---
title: Custom snowplow events for monitoring alerts
merge_request: 21963
author:
type: added
...@@ -54,18 +54,20 @@ We follow a simple formula roughly based on hungarian notation. ...@@ -54,18 +54,20 @@ We follow a simple formula roughly based on hungarian notation.
*Formula*: `element :<descriptor>_<type>` *Formula*: `element :<descriptor>_<type>`
- `descriptor`: The natural-language description of what the element is. On the login page, this could be `username`, or `password`. - `descriptor`: The natural-language description of what the element is. On the login page, this could be `username`, or `password`.
- `type`: A physical control on the page that can be seen by a user. - `type`: A generic control on the page that can be seen by a user.
- `_button` - `_button`
- `_link`
- `_tab`
- `_dropdown`
- `_field`
- `_checkbox` - `_checkbox`
- `_container`: an element that includes other elements, but doesn't present visible content itself. E.g., an element that has a third-party editor inside it, but which isn't the editor itself and so doesn't include the editor's content.
- `_content`: any element that contains text, images, or any other content displayed to the user.
- `_dropdown`
- `_field`: a text input element.
- `_link`
- `_modal`: a popup modal dialog, e.g., a confirmation prompt.
- `_placeholder`: a temporary element that appears while content is loading. For example, the elements that are displayed instead of discussions while the discussions are being fetched.
- `_radio` - `_radio`
- `_content` - `_tab`
*Note: This list is a work in progress. This list will eventually be the end-all enumeration of all available types. *Note: If none of the listed types are suitable, please open a merge request to add an appropriate type to the list.*
I.e., any element that does not end with something in this list is bad form.*
### Examples ### Examples
......
...@@ -178,18 +178,17 @@ module Gitlab ...@@ -178,18 +178,17 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def services_usage def services_usage
types = { service_counts = count(Service.active.where(template: false).where.not(type: 'JiraService').group(:type), fallback: Hash.new(-1))
SlackService: :projects_slack_notifications_active,
SlackSlashCommandsService: :projects_slack_slash_active, results = Service.available_services_names.each_with_object({}) do |service_name, response|
PrometheusService: :projects_prometheus_active, response["projects_#{service_name}_active".to_sym] = service_counts["#{service_name}_service".camelize] || 0
CustomIssueTrackerService: :projects_custom_issue_tracker_active, end
JenkinsService: :projects_jenkins_active,
MattermostService: :projects_mattermost_active
}
results = count(Service.active.by_type(types.keys).group(:type), fallback: Hash.new(-1)) # Keep old Slack keys for backward compatibility, https://gitlab.com/gitlab-data/analytics/issues/3241
types.each_with_object({}) { |(klass, key), response| response[key] = results[klass.to_s] || 0 } results[:projects_slack_notifications_active] = results[:projects_slack_active]
.merge(jira_usage) results[:projects_slack_slash_active] = results[:projects_slack_slash_commands_active]
results.merge(jira_usage)
end end
def jira_usage def jira_usage
...@@ -223,6 +222,7 @@ module Gitlab ...@@ -223,6 +222,7 @@ module Gitlab
results results
end end
# rubocop: enable CodeReuse/ActiveRecord
def user_preferences_usage def user_preferences_usage
{} # augmented in EE {} # augmented in EE
...@@ -233,7 +233,6 @@ module Gitlab ...@@ -233,7 +233,6 @@ module Gitlab
rescue ActiveRecord::StatementInvalid rescue ActiveRecord::StatementInvalid
fallback fallback
end end
# rubocop: enable CodeReuse/ActiveRecord
def approximate_counts def approximate_counts
approx_counts = Gitlab::Database::Count.approximate_counts(APPROXIMATE_COUNT_MODELS) approx_counts = Gitlab::Database::Count.approximate_counts(APPROXIMATE_COUNT_MODELS)
......
...@@ -9,14 +9,6 @@ module Sentry ...@@ -9,14 +9,6 @@ module Sentry
Error = Class.new(StandardError) Error = Class.new(StandardError)
MissingKeysError = Class.new(StandardError) MissingKeysError = Class.new(StandardError)
ResponseInvalidSizeError = Class.new(StandardError) ResponseInvalidSizeError = Class.new(StandardError)
BadRequestError = Class.new(StandardError)
SENTRY_API_SORT_VALUE_MAP = {
# <accepted_by_client> => <accepted_by_sentry_api>
'frequency' => 'freq',
'first_seen' => 'new',
'last_seen' => nil
}.freeze
attr_accessor :url, :token attr_accessor :url, :token
...@@ -25,30 +17,8 @@ module Sentry ...@@ -25,30 +17,8 @@ module Sentry
@token = token @token = token
end end
def list_issues(**keyword_args)
response = get_issues(keyword_args)
issues = response[:issues]
pagination = response[:pagination]
validate_size(issues)
handle_mapping_exceptions do
{
issues: map_to_errors(issues),
pagination: pagination
}
end
end
private private
def validate_size(issues)
return if Gitlab::Utils::DeepSize.new(issues).valid?
raise ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."
end
def handle_mapping_exceptions(&block) def handle_mapping_exceptions(&block)
yield yield
rescue KeyError => e rescue KeyError => e
...@@ -85,31 +55,6 @@ module Sentry ...@@ -85,31 +55,6 @@ module Sentry
handle_response(response) handle_response(response)
end end
def get_issues(**keyword_args)
response = http_get(
issues_api_url,
query: list_issue_sentry_query(keyword_args)
)
{
issues: response[:body],
pagination: Sentry::PaginationParser.parse(response[:headers])
}
end
def list_issue_sentry_query(issue_status:, limit:, sort: nil, search_term: '', cursor: nil)
unless SENTRY_API_SORT_VALUE_MAP.key?(sort)
raise BadRequestError, 'Invalid value for sort param'
end
{
query: "is:#{issue_status} #{search_term}".strip,
limit: limit,
sort: SENTRY_API_SORT_VALUE_MAP[sort],
cursor: cursor
}.compact
end
def handle_request_exceptions def handle_request_exceptions
yield yield
rescue Gitlab::HTTP::Error => e rescue Gitlab::HTTP::Error => e
...@@ -139,58 +84,5 @@ module Sentry ...@@ -139,58 +84,5 @@ module Sentry
def raise_error(message) def raise_error(message)
raise Client::Error, message raise Client::Error, message
end end
def issues_api_url
issues_url = URI(@url + '/issues/')
issues_url.path.squeeze!('/')
issues_url
end
def map_to_errors(issues)
issues.map(&method(:map_to_error))
end
def issue_url(id)
issues_url = @url + "/issues/#{id}"
parse_sentry_url(issues_url)
end
def project_url
parse_sentry_url(@url)
end
def parse_sentry_url(api_url)
url = ErrorTracking::ProjectErrorTrackingSetting.extract_sentry_external_url(api_url)
uri = URI(url)
uri.path.squeeze!('/')
# Remove trailing slash
uri = uri.to_s.gsub(/\/\z/, '')
uri
end
def map_to_error(issue)
Gitlab::ErrorTracking::Error.new(
id: issue.fetch('id'),
first_seen: issue.fetch('firstSeen', nil),
last_seen: issue.fetch('lastSeen', nil),
title: issue.fetch('title', nil),
type: issue.fetch('type', nil),
user_count: issue.fetch('userCount', nil),
count: issue.fetch('count', nil),
message: issue.dig('metadata', 'value'),
culprit: issue.fetch('culprit', nil),
external_url: issue_url(issue.fetch('id')),
short_id: issue.fetch('shortId', nil),
status: issue.fetch('status', nil),
frequency: issue.dig('stats', '24h'),
project_id: issue.dig('project', 'id'),
project_name: issue.dig('project', 'name'),
project_slug: issue.dig('project', 'slug')
)
end
end end
end end
...@@ -3,6 +3,31 @@ ...@@ -3,6 +3,31 @@
module Sentry module Sentry
class Client class Client
module Issue module Issue
BadRequestError = Class.new(StandardError)
SENTRY_API_SORT_VALUE_MAP = {
# <accepted_by_client> => <accepted_by_sentry_api>
'frequency' => 'freq',
'first_seen' => 'new',
'last_seen' => nil
}.freeze
def list_issues(**keyword_args)
response = get_issues(keyword_args)
issues = response[:issues]
pagination = response[:pagination]
validate_size(issues)
handle_mapping_exceptions do
{
issues: map_to_errors(issues),
pagination: pagination
}
end
end
def issue_details(issue_id:) def issue_details(issue_id:)
issue = get_issue(issue_id: issue_id) issue = get_issue(issue_id: issue_id)
...@@ -11,6 +36,37 @@ module Sentry ...@@ -11,6 +36,37 @@ module Sentry
private private
def get_issues(**keyword_args)
response = http_get(
issues_api_url,
query: list_issue_sentry_query(keyword_args)
)
{
issues: response[:body],
pagination: Sentry::PaginationParser.parse(response[:headers])
}
end
def list_issue_sentry_query(issue_status:, limit:, sort: nil, search_term: '', cursor: nil)
unless SENTRY_API_SORT_VALUE_MAP.key?(sort)
raise BadRequestError, 'Invalid value for sort param'
end
{
query: "is:#{issue_status} #{search_term}".strip,
limit: limit,
sort: SENTRY_API_SORT_VALUE_MAP[sort],
cursor: cursor
}.compact
end
def validate_size(issues)
return if Gitlab::Utils::DeepSize.new(issues).valid?
raise ResponseInvalidSizeError, "Sentry API response is too big. Limit is #{Gitlab::Utils::DeepSize.human_default_max_size}."
end
def get_issue(issue_id:) def get_issue(issue_id:)
http_get(issue_api_url(issue_id))[:body] http_get(issue_api_url(issue_id))[:body]
end end
...@@ -19,6 +75,13 @@ module Sentry ...@@ -19,6 +75,13 @@ module Sentry
http_put(issue_api_url(issue_id), params)[:body] http_put(issue_api_url(issue_id), params)[:body]
end end
def issues_api_url
issues_url = URI("#{url}/issues/")
issues_url.path.squeeze!('/')
issues_url
end
def issue_api_url(issue_id) def issue_api_url(issue_id)
issue_url = URI(url) issue_url = URI(url)
issue_url.path = "/api/0/issues/#{CGI.escape(issue_id.to_s)}/" issue_url.path = "/api/0/issues/#{CGI.escape(issue_id.to_s)}/"
...@@ -35,6 +98,50 @@ module Sentry ...@@ -35,6 +98,50 @@ module Sentry
gitlab_plugin.dig('issue', 'url') gitlab_plugin.dig('issue', 'url')
end end
def issue_url(id)
parse_sentry_url("#{url}/issues/#{id}")
end
def project_url
parse_sentry_url(url)
end
def parse_sentry_url(api_url)
url = ErrorTracking::ProjectErrorTrackingSetting.extract_sentry_external_url(api_url)
uri = URI(url)
uri.path.squeeze!('/')
# Remove trailing slash
uri = uri.to_s.gsub(/\/\z/, '')
uri
end
def map_to_errors(issues)
issues.map(&method(:map_to_error))
end
def map_to_error(issue)
Gitlab::ErrorTracking::Error.new(
id: issue.fetch('id'),
first_seen: issue.fetch('firstSeen', nil),
last_seen: issue.fetch('lastSeen', nil),
title: issue.fetch('title', nil),
type: issue.fetch('type', nil),
user_count: issue.fetch('userCount', nil),
count: issue.fetch('count', nil),
message: issue.dig('metadata', 'value'),
culprit: issue.fetch('culprit', nil),
external_url: issue_url(issue.fetch('id')),
short_id: issue.fetch('shortId', nil),
status: issue.fetch('status', nil),
frequency: issue.dig('stats', '24h'),
project_id: issue.dig('project', 'id'),
project_name: issue.dig('project', 'name'),
project_slug: issue.dig('project', 'slug')
)
end
def map_to_detailed_error(issue) def map_to_detailed_error(issue)
Gitlab::ErrorTracking::DetailedError.new( Gitlab::ErrorTracking::DetailedError.new(
id: issue.fetch('id'), id: issue.fetch('id'),
......
...@@ -269,9 +269,6 @@ msgstr "" ...@@ -269,9 +269,6 @@ msgstr ""
msgid "%{firstLabel} +%{labelCount} more" msgid "%{firstLabel} +%{labelCount} more"
msgstr "" msgstr ""
msgid "%{from} to %{to}"
msgstr ""
msgid "%{global_id} is not a valid id for %{expected_type}." msgid "%{global_id} is not a valid id for %{expected_type}."
msgstr "" msgstr ""
...@@ -370,6 +367,9 @@ msgstr "" ...@@ -370,6 +367,9 @@ msgstr ""
msgid "%{spammable_titlecase} was submitted to Akismet successfully." msgid "%{spammable_titlecase} was submitted to Akismet successfully."
msgstr "" msgstr ""
msgid "%{start} to %{end}"
msgstr ""
msgid "%{state} epics" msgid "%{state} epics"
msgstr "" msgstr ""
...@@ -557,6 +557,9 @@ msgid_plural "%d groups" ...@@ -557,6 +557,9 @@ msgid_plural "%d groups"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "1 hour"
msgstr ""
msgid "1 merged merge request" msgid "1 merged merge request"
msgid_plural "%{merge_requests} merged merge requests" msgid_plural "%{merge_requests} merged merge requests"
msgstr[0] "" msgstr[0] ""
...@@ -607,6 +610,9 @@ msgstr "" ...@@ -607,6 +610,9 @@ msgstr ""
msgid "20-29 contributions" msgid "20-29 contributions"
msgstr "" msgstr ""
msgid "24 hours"
msgstr ""
msgid "2FA" msgid "2FA"
msgstr "" msgstr ""
...@@ -619,6 +625,9 @@ msgstr "" ...@@ -619,6 +625,9 @@ msgstr ""
msgid "3 hours" msgid "3 hours"
msgstr "" msgstr ""
msgid "30 days"
msgstr ""
msgid "30 minutes" msgid "30 minutes"
msgstr "" msgstr ""
...@@ -640,6 +649,9 @@ msgstr "" ...@@ -640,6 +649,9 @@ msgstr ""
msgid "404|Please contact your GitLab administrator if you think this is a mistake." msgid "404|Please contact your GitLab administrator if you think this is a mistake."
msgstr "" msgstr ""
msgid "7 days"
msgstr ""
msgid "8 hours" msgid "8 hours"
msgstr "" msgstr ""
...@@ -11478,7 +11490,7 @@ msgstr "" ...@@ -11478,7 +11490,7 @@ msgstr ""
msgid "Metrics|Legend label (optional)" msgid "Metrics|Legend label (optional)"
msgstr "" msgstr ""
msgid "Metrics|Link contains an invalid time window." msgid "Metrics|Link contains an invalid time window, please verify the link to see the requested time range."
msgstr "" msgstr ""
msgid "Metrics|Max" msgid "Metrics|Max"
...@@ -18797,6 +18809,9 @@ msgstr "" ...@@ -18797,6 +18809,9 @@ msgstr ""
msgid "ThreatMonitoring|Requests" msgid "ThreatMonitoring|Requests"
msgstr "" msgstr ""
msgid "ThreatMonitoring|Show last"
msgstr ""
msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics" msgid "ThreatMonitoring|Something went wrong, unable to fetch WAF statistics"
msgstr "" msgstr ""
......
...@@ -19,4 +19,5 @@ group :test do ...@@ -19,4 +19,5 @@ group :test do
gem 'pry-byebug', '~> 3.5.1', platform: :mri gem 'pry-byebug', '~> 3.5.1', platform: :mri
gem "ruby-debug-ide", "~> 0.7.0" gem "ruby-debug-ide", "~> 0.7.0"
gem "debase", "~> 0.2.4.1" gem "debase", "~> 0.2.4.1"
gem 'timecop', '~> 0.9.1'
end end
...@@ -99,6 +99,7 @@ GEM ...@@ -99,6 +99,7 @@ GEM
childprocess (>= 0.5, < 4.0) childprocess (>= 0.5, < 4.0)
rubyzip (>= 1.2.2) rubyzip (>= 1.2.2)
thread_safe (0.3.6) thread_safe (0.3.6)
timecop (0.9.1)
tzinfo (1.2.5) tzinfo (1.2.5)
thread_safe (~> 0.1) thread_safe (~> 0.1)
unf (0.1.4) unf (0.1.4)
...@@ -128,6 +129,7 @@ DEPENDENCIES ...@@ -128,6 +129,7 @@ DEPENDENCIES
rspec_junit_formatter (~> 0.4.1) rspec_junit_formatter (~> 0.4.1)
ruby-debug-ide (~> 0.7.0) ruby-debug-ide (~> 0.7.0)
selenium-webdriver (~> 3.12) selenium-webdriver (~> 3.12)
timecop (~> 0.9.1)
BUNDLED WITH BUNDLED WITH
1.17.3 1.17.3
...@@ -488,8 +488,9 @@ module QA ...@@ -488,8 +488,9 @@ module QA
end end
autoload :Api, 'qa/support/api' autoload :Api, 'qa/support/api'
autoload :Dates, 'qa/support/dates' autoload :Dates, 'qa/support/dates'
autoload :Waiter, 'qa/support/waiter' autoload :Repeater, 'qa/support/repeater'
autoload :Retrier, 'qa/support/retrier' autoload :Retrier, 'qa/support/retrier'
autoload :Waiter, 'qa/support/waiter'
autoload :WaitForRequests, 'qa/support/wait_for_requests' autoload :WaitForRequests, 'qa/support/wait_for_requests'
end end
end end
......
...@@ -26,20 +26,20 @@ module QA ...@@ -26,20 +26,20 @@ module QA
wait_for_requests wait_for_requests
end end
def wait(max: 60, interval: 0.1, reload: true) def wait(max: 60, interval: 0.1, reload: true, raise_on_failure: false)
QA::Support::Waiter.wait(max: max, interval: interval) do Support::Waiter.wait_until(max_duration: max, sleep_interval: interval, raise_on_failure: raise_on_failure) do
yield || (reload && refresh && false) yield || (reload && refresh && false)
end end
end end
def retry_until(max_attempts: 3, reload: false, sleep_interval: 0) def retry_until(max_attempts: 3, reload: false, sleep_interval: 0, raise_on_failure: false)
QA::Support::Retrier.retry_until(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do Support::Retrier.retry_until(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval, raise_on_failure: raise_on_failure) do
yield yield
end end
end end
def retry_on_exception(max_attempts: 3, reload: false, sleep_interval: 0.5) def retry_on_exception(max_attempts: 3, reload: false, sleep_interval: 0.5)
QA::Support::Retrier.retry_on_exception(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do Support::Retrier.retry_on_exception(max_attempts: max_attempts, reload_page: (reload && self), sleep_interval: sleep_interval) do
yield yield
end end
end end
......
...@@ -4,6 +4,7 @@ module QA ...@@ -4,6 +4,7 @@ module QA
module Resource module Resource
module Events module Events
MAX_WAIT = 10 MAX_WAIT = 10
RAISE_ON_FAILURE = true
EventNotFoundError = Class.new(RuntimeError) EventNotFoundError = Class.new(RuntimeError)
...@@ -21,7 +22,7 @@ module QA ...@@ -21,7 +22,7 @@ module QA
end end
def wait_for_event def wait_for_event
event_found = QA::Support::Waiter.wait(max: max_wait) do event_found = Support::Waiter.wait_until(max_duration: max_wait, raise_on_failure: raise_on_failure) do
yield yield
end end
...@@ -31,6 +32,10 @@ module QA ...@@ -31,6 +32,10 @@ module QA
def max_wait def max_wait
MAX_WAIT MAX_WAIT
end end
def raise_on_failure
RAISE_ON_FAILURE
end
end end
end end
end end
......
# frozen_string_literal: true
require 'active_support/inflector'
module QA
module Support
module Repeater
DEFAULT_MAX_WAIT_TIME = 60
RetriesExceededError = Class.new(RuntimeError)
WaitExceededError = Class.new(RuntimeError)
def repeat_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: true, retry_on_exception: false)
attempts = 0
start = Time.now
begin
while remaining_attempts?(attempts, max_attempts) && remaining_time?(start, max_duration)
QA::Runtime::Logger.debug("Attempt number #{attempts + 1}") if max_attempts
result = yield
return result if result
sleep_and_reload_if_needed(sleep_interval, reload_page)
attempts += 1
end
rescue StandardError, RSpec::Expectations::ExpectationNotMetError
raise unless retry_on_exception
attempts += 1
if remaining_attempts?(attempts, max_attempts) && remaining_time?(start, max_duration)
sleep_and_reload_if_needed(sleep_interval, reload_page)
retry
else
raise
end
end
if raise_on_failure
raise RetriesExceededError, "Retry condition not met after #{max_attempts} #{'attempt'.pluralize(max_attempts)}" unless remaining_attempts?(attempts, max_attempts)
raise WaitExceededError, "Wait condition not met after #{max_duration} #{'second'.pluralize(max_duration)}"
end
false
end
private
def sleep_and_reload_if_needed(sleep_interval, reload_page)
sleep(sleep_interval)
reload_page.refresh if reload_page
end
def remaining_attempts?(attempts, max_attempts)
max_attempts ? attempts < max_attempts : true
end
def remaining_time?(start, max_duration)
max_duration ? Time.now - start < max_duration : true
end
end
end
end
...@@ -3,49 +3,61 @@ ...@@ -3,49 +3,61 @@
module QA module QA
module Support module Support
module Retrier module Retrier
extend Repeater
module_function module_function
def retry_on_exception(max_attempts: 3, reload_page: nil, sleep_interval: 0.5) def retry_on_exception(max_attempts: 3, reload_page: nil, sleep_interval: 0.5)
QA::Runtime::Logger.debug("with retry_on_exception: max_attempts #{max_attempts}; sleep_interval #{sleep_interval}") QA::Runtime::Logger.debug(
<<~MSG.tr("\n", ' ')
attempts = 0 with retry_on_exception: max_attempts: #{max_attempts};
reload_page: #{reload_page};
sleep_interval: #{sleep_interval}
MSG
)
begin result = nil
QA::Runtime::Logger.debug("Attempt number #{attempts + 1}") repeat_until(
yield max_attempts: max_attempts,
rescue StandardError, RSpec::Expectations::ExpectationNotMetError reload_page: reload_page,
sleep sleep_interval sleep_interval: sleep_interval,
reload_page.refresh if reload_page retry_on_exception: true
attempts += 1 ) do
result = yield
retry if attempts < max_attempts # This method doesn't care what the return value of the block is.
QA::Runtime::Logger.debug("Raising exception after #{max_attempts} attempts") # We set it to `true` so that it doesn't repeat if there's no exception
raise true
end end
end QA::Runtime::Logger.debug("ended retry_on_exception")
def retry_until(max_attempts: 3, reload_page: nil, sleep_interval: 0, exit_on_failure: false)
QA::Runtime::Logger.debug("with retry_until: max_attempts #{max_attempts}; sleep_interval #{sleep_interval}; reload_page:#{reload_page}")
attempts = 0
while attempts < max_attempts result
QA::Runtime::Logger.debug("Attempt number #{attempts + 1}") end
result = yield
return result if result
sleep sleep_interval def retry_until(max_attempts: nil, max_duration: nil, reload_page: nil, sleep_interval: 0, raise_on_failure: false, retry_on_exception: false)
# For backwards-compatibility
max_attempts = 3 if max_attempts.nil? && max_duration.nil?
reload_page.refresh if reload_page start_msg ||= ["with retry_until:"]
start_msg << "max_attempts: #{max_attempts};" if max_attempts
start_msg << "max_duration: #{max_duration};" if max_duration
start_msg << "reload_page: #{reload_page}; sleep_interval: #{sleep_interval}; raise_on_failure: #{raise_on_failure}; retry_on_exception: #{retry_on_exception}"
QA::Runtime::Logger.debug(start_msg.join(' '))
attempts += 1 result = nil
end repeat_until(
max_attempts: max_attempts,
if exit_on_failure max_duration: max_duration,
QA::Runtime::Logger.debug("Raising exception after #{max_attempts} attempts") reload_page: reload_page,
raise sleep_interval: sleep_interval,
raise_on_failure: raise_on_failure,
retry_on_exception: retry_on_exception
) do
result = yield
end end
QA::Runtime::Logger.debug("ended retry_until")
false result
end end
end end
end end
......
...@@ -3,30 +3,39 @@ ...@@ -3,30 +3,39 @@
module QA module QA
module Support module Support
module Waiter module Waiter
DEFAULT_MAX_WAIT_TIME = 60 extend Repeater
module_function module_function
def wait(max: DEFAULT_MAX_WAIT_TIME, interval: 0.1) def wait(max: singleton_class::DEFAULT_MAX_WAIT_TIME, interval: 0.1)
QA::Runtime::Logger.debug("with wait: max #{max}; interval #{interval}") wait_until(max_duration: max, sleep_interval: interval, raise_on_failure: false) do
start = Time.now yield
end
end
while Time.now - start < max def wait_until(max_duration: singleton_class::DEFAULT_MAX_WAIT_TIME, reload_page: nil, sleep_interval: 0.1, raise_on_failure: false, retry_on_exception: false)
result = yield QA::Runtime::Logger.debug(
if result <<~MSG.tr("\n", ' ')
log_end(Time.now - start) with wait_until: max_duration: #{max_duration};
return result reload_page: #{reload_page};
end sleep_interval: #{sleep_interval};
raise_on_failure: #{raise_on_failure}
MSG
)
sleep(interval) result = nil
self.repeat_until(
max_duration: max_duration,
reload_page: reload_page,
sleep_interval: sleep_interval,
raise_on_failure: raise_on_failure,
retry_on_exception: retry_on_exception
) do
result = yield
end end
log_end(Time.now - start) QA::Runtime::Logger.debug("ended wait_until")
false
end
def self.log_end(duration) result
QA::Runtime::Logger.debug("ended wait after #{duration} seconds")
end end
end end
end end
......
...@@ -12,7 +12,7 @@ module QA ...@@ -12,7 +12,7 @@ module QA
fill_in 'password', with: QA::Runtime::Env.github_password fill_in 'password', with: QA::Runtime::Env.github_password
click_on 'Sign in' click_on 'Sign in'
Support::Retrier.retry_until(exit_on_failure: true, sleep_interval: 35) do Support::Retrier.retry_until(raise_on_failure: true, sleep_interval: 35) do
otp = OnePassword::CLI.new.otp otp = OnePassword::CLI.new.otp
fill_in 'otp', with: otp fill_in 'otp', with: otp
......
...@@ -18,7 +18,7 @@ module QA ...@@ -18,7 +18,7 @@ module QA
dropdown_element = find('.setting-name', text: "Credentials").find(:xpath, "..").find('select') dropdown_element = find('.setting-name', text: "Credentials").find(:xpath, "..").find('select')
QA::Support::Retrier.retry_until(exit_on_failure: true) do QA::Support::Retrier.retry_until(raise_on_failure: true) do
dropdown_element.select "GitLab API token (#{token_description})" dropdown_element.select "GitLab API token (#{token_description})"
dropdown_element.value != '' dropdown_element.value != ''
end end
......
...@@ -14,7 +14,7 @@ module QA ...@@ -14,7 +14,7 @@ module QA
def visit! def visit!
super super
QA::Support::Retrier.retry_until(sleep_interval: 3, reload_page: page, max_attempts: 20, exit_on_failure: true) do QA::Support::Retrier.retry_until(sleep_interval: 3, reload_page: page, max_attempts: 20, raise_on_failure: true) do
page.has_text? 'Welcome to Jenkins!' page.has_text? 'Welcome to Jenkins!'
end end
end end
......
...@@ -69,11 +69,11 @@ describe QA::Page::Base do ...@@ -69,11 +69,11 @@ describe QA::Page::Base do
it 'does not refresh' do it 'does not refresh' do
expect(subject).not_to receive(:refresh) expect(subject).not_to receive(:refresh)
subject.wait(max: 0.01) { true } subject.wait(max: 0.01, raise_on_failure: false) { true }
end end
it 'returns true' do it 'returns true' do
expect(subject.wait(max: 0.1) { true }).to be_truthy expect(subject.wait(max: 0.1, raise_on_failure: false) { true }).to be_truthy
end end
end end
...@@ -81,13 +81,13 @@ describe QA::Page::Base do ...@@ -81,13 +81,13 @@ describe QA::Page::Base do
it 'refreshes' do it 'refreshes' do
expect(subject).to receive(:refresh).at_least(:once) expect(subject).to receive(:refresh).at_least(:once)
subject.wait(max: 0.01) { false } subject.wait(max: 0.01, raise_on_failure: false) { false }
end end
it 'returns false' do it 'returns false' do
allow(subject).to receive(:refresh) allow(subject).to receive(:refresh)
expect(subject.wait(max: 0.01) { false }).to be_falsey expect(subject.wait(max: 0.01, raise_on_failure: false) { false }).to be_falsey
end end
end end
end end
......
...@@ -31,18 +31,18 @@ describe QA::Support::Page::Logging do ...@@ -31,18 +31,18 @@ describe QA::Support::Page::Logging do
expect { subject.wait(max: 0) {} } expect { subject.wait(max: 0) {} }
.to output(/next wait uses reload: true/).to_stdout_from_any_process .to output(/next wait uses reload: true/).to_stdout_from_any_process
expect { subject.wait(max: 0) {} } expect { subject.wait(max: 0) {} }
.to output(/with wait/).to_stdout_from_any_process .to output(/with wait_until/).to_stdout_from_any_process
expect { subject.wait(max: 0) {} } expect { subject.wait(max: 0) {} }
.to output(/ended wait after .* seconds$/).to_stdout_from_any_process .to output(/ended wait_until$/).to_stdout_from_any_process
end end
it 'logs wait with reload false' do it 'logs wait with reload false' do
expect { subject.wait(max: 0, reload: false) {} } expect { subject.wait(max: 0, reload: false) {} }
.to output(/next wait uses reload: false/).to_stdout_from_any_process .to output(/next wait uses reload: false/).to_stdout_from_any_process
expect { subject.wait(max: 0, reload: false) {} } expect { subject.wait(max: 0, reload: false) {} }
.to output(/with wait/).to_stdout_from_any_process .to output(/with wait_until/).to_stdout_from_any_process
expect { subject.wait(max: 0, reload: false) {} } expect { subject.wait(max: 0, reload: false) {} }
.to output(/ended wait after .* seconds$/).to_stdout_from_any_process .to output(/ended wait_until$/).to_stdout_from_any_process
end end
it 'logs scroll_to' do it 'logs scroll_to' do
......
...@@ -33,6 +33,7 @@ describe QA::Resource::Events::Project do ...@@ -33,6 +33,7 @@ describe QA::Resource::Events::Project do
before do before do
allow(subject).to receive(:max_wait).and_return(0.01) allow(subject).to receive(:max_wait).and_return(0.01)
allow(subject).to receive(:raise_on_failure).and_return(false)
allow(subject).to receive(:parse_body).and_return(all_events) allow(subject).to receive(:parse_body).and_return(all_events)
end end
......
This diff is collapsed.
# frozen_string_literal: true
require 'logger'
require 'timecop'
describe QA::Support::Retrier do
before do
logger = ::Logger.new $stdout
logger.level = ::Logger::DEBUG
QA::Runtime::Logger.logger = logger
end
describe '.retry_until' do
context 'when the condition is true' do
it 'logs max attempts (3 by default)' do
expect { subject.retry_until { true } }
.to output(/with retry_until: max_attempts: 3; reload_page: ; sleep_interval: 0; raise_on_failure: false; retry_on_exception: false/).to_stdout_from_any_process
end
it 'logs max duration' do
expect { subject.retry_until(max_duration: 1) { true } }
.to output(/with retry_until: max_duration: 1; reload_page: ; sleep_interval: 0; raise_on_failure: false; retry_on_exception: false/).to_stdout_from_any_process
end
it 'logs the end' do
expect { subject.retry_until { true } }
.to output(/ended retry_until$/).to_stdout_from_any_process
end
end
context 'when the condition is false' do
it 'logs the start' do
expect { subject.retry_until(max_duration: 0) { false } }
.to output(/with retry_until: max_duration: 0; reload_page: ; sleep_interval: 0; raise_on_failure: false; retry_on_exception: false/).to_stdout_from_any_process
end
it 'logs the end' do
expect { subject.retry_until(max_duration: 0) { false } }
.to output(/ended retry_until$/).to_stdout_from_any_process
end
end
context 'when max_duration and max_attempts are nil' do
it 'sets max attempts to 3 by default' do
expect(subject).to receive(:repeat_until).with(hash_including(max_attempts: 3))
subject.retry_until
end
end
it 'sets sleep_interval to 0 by default' do
expect(subject).to receive(:repeat_until).with(hash_including(sleep_interval: 0))
subject.retry_until
end
it 'sets raise_on_failure to false by default' do
expect(subject).to receive(:repeat_until).with(hash_including(raise_on_failure: false))
subject.retry_until
end
it 'sets retry_on_exception to false by default' do
expect(subject).to receive(:repeat_until).with(hash_including(retry_on_exception: false))
subject.retry_until
end
end
describe '.retry_on_exception' do
context 'when the condition is true' do
it 'logs max_attempts, reload_page, and sleep_interval parameters' do
expect { subject.retry_on_exception(max_attempts: 1, reload_page: nil, sleep_interval: 0) { true } }
.to output(/with retry_on_exception: max_attempts: 1; reload_page: ; sleep_interval: 0/).to_stdout_from_any_process
end
it 'logs the end' do
expect { subject.retry_on_exception(max_attempts: 1, reload_page: nil, sleep_interval: 0) { true } }
.to output(/ended retry_on_exception$/).to_stdout_from_any_process
end
end
context 'when the condition is false' do
it 'logs the start' do
expect { subject.retry_on_exception(max_attempts: 1, reload_page: nil, sleep_interval: 0) { false } }
.to output(/with retry_on_exception: max_attempts: 1; reload_page: ; sleep_interval: 0/).to_stdout_from_any_process
end
it 'logs the end' do
expect { subject.retry_on_exception(max_attempts: 1, reload_page: nil, sleep_interval: 0) { false } }
.to output(/ended retry_on_exception$/).to_stdout_from_any_process
end
end
it 'does not repeat if no exception is raised' do
loop_counter = 0
return_value = "test passed"
expect(
subject.retry_on_exception(max_attempts: 2) do
loop_counter += 1
return_value
end
).to eq(return_value)
expect(loop_counter).to eq(1)
end
it 'sets retry_on_exception to true' do
expect(subject).to receive(:repeat_until).with(hash_including(retry_on_exception: true))
subject.retry_on_exception
end
it 'sets max_attempts to 3 by default' do
expect(subject).to receive(:repeat_until).with(hash_including(max_attempts: 3))
subject.retry_on_exception
end
it 'sets sleep_interval to 0.5 by default' do
expect(subject).to receive(:repeat_until).with(hash_including(sleep_interval: 0.5))
subject.retry_on_exception
end
end
end
...@@ -9,29 +9,53 @@ describe QA::Support::Waiter do ...@@ -9,29 +9,53 @@ describe QA::Support::Waiter do
QA::Runtime::Logger.logger = logger QA::Runtime::Logger.logger = logger
end end
describe '.wait' do describe '.wait_until' do
context 'when the condition is true' do context 'when the condition is true' do
it 'logs the start' do it 'logs the start' do
expect { subject.wait(max: 0) {} } expect { subject.wait_until(max_duration: 0, raise_on_failure: false) { true } }
.to output(/with wait: max 0; interval 0.1/).to_stdout_from_any_process .to output(/with wait_until: max_duration: 0; reload_page: ; sleep_interval: 0.1/).to_stdout_from_any_process
end end
it 'logs the end' do it 'logs the end' do
expect { subject.wait(max: 0) {} } expect { subject.wait_until(max_duration: 0, raise_on_failure: false) { true } }
.to output(/ended wait after .* seconds$/).to_stdout_from_any_process .to output(/ended wait_until$/).to_stdout_from_any_process
end end
end end
context 'when the condition is false' do context 'when the condition is false' do
it 'logs the start' do it 'logs the start' do
expect { subject.wait(max: 0) { false } } expect { subject.wait_until(max_duration: 0, raise_on_failure: false) { false } }
.to output(/with wait: max 0; interval 0.1/).to_stdout_from_any_process .to output(/with wait_until: max_duration: 0; reload_page: ; sleep_interval: 0.1/).to_stdout_from_any_process
end end
it 'logs the end' do it 'logs the end' do
expect { subject.wait(max: 0) { false } } expect { subject.wait_until(max_duration: 0, raise_on_failure: false) { false } }
.to output(/ended wait after .* seconds$/).to_stdout_from_any_process .to output(/ended wait_until$/).to_stdout_from_any_process
end end
end end
it 'sets max_duration to 60 by default' do
expect(subject).to receive(:repeat_until).with(hash_including(max_duration: 60))
subject.wait_until
end
it 'sets sleep_interval to 0.1 by default' do
expect(subject).to receive(:repeat_until).with(hash_including(sleep_interval: 0.1))
subject.wait_until
end
it 'sets raise_on_failure to false by default' do
expect(subject).to receive(:repeat_until).with(hash_including(raise_on_failure: false))
subject.wait_until
end
it 'sets retry_on_exception to false by default' do
expect(subject).to receive(:repeat_until).with(hash_including(retry_on_exception: false))
subject.wait_until
end
end end
end end
...@@ -10,9 +10,9 @@ describe('PasteMarkdownTable', () => { ...@@ -10,9 +10,9 @@ describe('PasteMarkdownTable', () => {
value: { value: {
getData: jest.fn().mockImplementation(type => { getData: jest.fn().mockImplementation(type => {
if (type === 'text/html') { if (type === 'text/html') {
return '<table><tr><td></td></tr></table>'; return '<table><tr><td>First</td><td>Second</td></tr></table>';
} }
return 'hello world'; return 'First\tSecond';
}), }),
}, },
}); });
...@@ -24,39 +24,48 @@ describe('PasteMarkdownTable', () => { ...@@ -24,39 +24,48 @@ describe('PasteMarkdownTable', () => {
it('return false when no HTML data is provided', () => { it('return false when no HTML data is provided', () => {
data.types = ['text/plain']; data.types = ['text/plain'];
expect(PasteMarkdownTable.isTable(data)).toBe(false); expect(new PasteMarkdownTable(data).isTable()).toBe(false);
}); });
it('returns false when no text data is provided', () => { it('returns false when no text data is provided', () => {
data.types = ['text/html']; data.types = ['text/html'];
expect(PasteMarkdownTable.isTable(data)).toBe(false); expect(new PasteMarkdownTable(data).isTable()).toBe(false);
}); });
it('returns true when a table is provided in both text and HTML', () => { it('returns true when a table is provided in both text and HTML', () => {
data.types = ['text/html', 'text/plain']; data.types = ['text/html', 'text/plain'];
expect(PasteMarkdownTable.isTable(data)).toBe(true); expect(new PasteMarkdownTable(data).isTable()).toBe(true);
}); });
it('returns false when no HTML table is included', () => { it('returns false when no HTML table is included', () => {
data.types = ['text/html', 'text/plain']; data.types = ['text/html', 'text/plain'];
data.getData = jest.fn().mockImplementation(() => 'nothing'); data.getData = jest.fn().mockImplementation(() => 'nothing');
expect(PasteMarkdownTable.isTable(data)).toBe(false); expect(new PasteMarkdownTable(data).isTable()).toBe(false);
}); });
});
describe('convertToTableMarkdown', () => { it('returns false when the number of rows are not consistent', () => {
let converter; data.types = ['text/html', 'text/plain'];
data.getData = jest.fn().mockImplementation(mimeType => {
if (mimeType === 'text/html') {
return '<table><tr><td>def test<td></tr></table>';
}
return "def test\n 'hello'\n";
});
beforeEach(() => { expect(new PasteMarkdownTable(data).isTable()).toBe(false);
converter = new PasteMarkdownTable(data);
}); });
});
describe('convertToTableMarkdown', () => {
it('returns a Markdown table', () => { it('returns a Markdown table', () => {
data.types = ['text/html', 'text/plain'];
data.getData = jest.fn().mockImplementation(type => { data.getData = jest.fn().mockImplementation(type => {
if (type === 'text/plain') { if (type === 'text/html') {
return '<table><tr><td>First</td><td>Last</td><tr><td>John</td><td>Doe</td><tr><td>Jane</td><td>Doe</td></table>';
} else if (type === 'text/plain') {
return 'First\tLast\nJohn\tDoe\nJane\tDoe'; return 'First\tLast\nJohn\tDoe\nJane\tDoe';
} }
...@@ -70,12 +79,18 @@ describe('PasteMarkdownTable', () => { ...@@ -70,12 +79,18 @@ describe('PasteMarkdownTable', () => {
'| Jane | Doe |', '| Jane | Doe |',
].join('\n'); ].join('\n');
const converter = new PasteMarkdownTable(data);
expect(converter.isTable()).toBe(true);
expect(converter.convertToTableMarkdown()).toBe(expected); expect(converter.convertToTableMarkdown()).toBe(expected);
}); });
it('returns a Markdown table with rows normalized', () => { it('returns a Markdown table with rows normalized', () => {
data.types = ['text/html', 'text/plain'];
data.getData = jest.fn().mockImplementation(type => { data.getData = jest.fn().mockImplementation(type => {
if (type === 'text/plain') { if (type === 'text/html') {
return '<table><tr><td>First</td><td>Last</td><tr><td>John</td><td>Doe</td><tr><td>Jane</td><td>/td></table>';
} else if (type === 'text/plain') {
return 'First\tLast\nJohn\tDoe\nJane'; return 'First\tLast\nJohn\tDoe\nJane';
} }
...@@ -89,6 +104,9 @@ describe('PasteMarkdownTable', () => { ...@@ -89,6 +104,9 @@ describe('PasteMarkdownTable', () => {
'| Jane | |', '| Jane | |',
].join('\n'); ].join('\n');
const converter = new PasteMarkdownTable(data);
expect(converter.isTable()).toBe(true);
expect(converter.convertToTableMarkdown()).toBe(expected); expect(converter.convertToTableMarkdown()).toBe(expected);
}); });
}); });
......
...@@ -10,7 +10,6 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p ...@@ -10,7 +10,6 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p
import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; import GroupEmptyState from '~/monitoring/components/group_empty_state.vue';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types'; import * as types from '~/monitoring/stores/mutation_types';
import * as monitoringUtils from '~/monitoring/utils';
import { setupComponentStore, propsData } from '../init_utils'; import { setupComponentStore, propsData } from '../init_utils';
import { import {
metricsGroupsAPIResponse, metricsGroupsAPIResponse,
...@@ -24,13 +23,12 @@ const localVue = createLocalVue(); ...@@ -24,13 +23,12 @@ const localVue = createLocalVue();
const expectedPanelCount = 2; const expectedPanelCount = 2;
describe('Dashboard', () => { describe('Dashboard', () => {
let DashboardComponent;
let store; let store;
let wrapper; let wrapper;
let mock; let mock;
const createShallowWrapper = (props = {}, options = {}) => { const createShallowWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(localVue.extend(DashboardComponent), { wrapper = shallowMount(Dashboard, {
localVue, localVue,
sync: false, sync: false,
propsData: { ...propsData, ...props }, propsData: { ...propsData, ...props },
...@@ -40,7 +38,7 @@ describe('Dashboard', () => { ...@@ -40,7 +38,7 @@ describe('Dashboard', () => {
}; };
const createMountedWrapper = (props = {}, options = {}) => { const createMountedWrapper = (props = {}, options = {}) => {
wrapper = mount(localVue.extend(DashboardComponent), { wrapper = mount(Dashboard, {
localVue, localVue,
sync: false, sync: false,
propsData: { ...propsData, ...props }, propsData: { ...propsData, ...props },
...@@ -51,7 +49,6 @@ describe('Dashboard', () => { ...@@ -51,7 +49,6 @@ describe('Dashboard', () => {
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
DashboardComponent = localVue.extend(Dashboard);
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
}); });
...@@ -137,7 +134,6 @@ describe('Dashboard', () => { ...@@ -137,7 +134,6 @@ describe('Dashboard', () => {
}); });
it('fetches the metrics data with proper time window', done => { it('fetches the metrics data with proper time window', done => {
const getTimeDiffSpy = jest.spyOn(monitoringUtils, 'getTimeDiff');
jest.spyOn(store, 'dispatch'); jest.spyOn(store, 'dispatch');
createMountedWrapper( createMountedWrapper(
...@@ -154,7 +150,6 @@ describe('Dashboard', () => { ...@@ -154,7 +150,6 @@ describe('Dashboard', () => {
.$nextTick() .$nextTick()
.then(() => { .then(() => {
expect(store.dispatch).toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalled();
expect(getTimeDiffSpy).toHaveBeenCalled();
done(); done();
}) })
......
import { mount, createLocalVue } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue'; import Dashboard from '~/monitoring/components/dashboard.vue';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import { propsData } from '../init_utils'; import { propsData } from '../init_utils';
import axios from '~/lib/utils/axios_utils';
const localVue = createLocalVue();
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -15,10 +15,10 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -15,10 +15,10 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('dashboard invalid url parameters', () => { describe('dashboard invalid url parameters', () => {
let store; let store;
let wrapper; let wrapper;
let mock;
const createMountedWrapper = (props = {}, options = {}) => { const createMountedWrapper = (props = {}, options = {}) => {
wrapper = mount(localVue.extend(Dashboard), { wrapper = mount(Dashboard, {
localVue,
sync: false, sync: false,
propsData: { ...propsData, ...props }, propsData: { ...propsData, ...props },
store, store,
...@@ -28,12 +28,14 @@ describe('dashboard invalid url parameters', () => { ...@@ -28,12 +28,14 @@ describe('dashboard invalid url parameters', () => {
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
mock = new MockAdapter(axios);
}); });
afterEach(() => { afterEach(() => {
if (wrapper) { if (wrapper) {
wrapper.destroy(); wrapper.destroy();
} }
mock.restore();
}); });
it('shows an error message if invalid url parameters are passed', done => { it('shows an error message if invalid url parameters are passed', done => {
...@@ -46,7 +48,6 @@ describe('dashboard invalid url parameters', () => { ...@@ -46,7 +48,6 @@ describe('dashboard invalid url parameters', () => {
.$nextTick() .$nextTick()
.then(() => { .then(() => {
expect(createFlash).toHaveBeenCalled(); expect(createFlash).toHaveBeenCalled();
done(); done();
}) })
.catch(done.fail); .catch(done.fail);
......
import { mount, createLocalVue } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlDropdownItem } from '@gitlab/ui'; import { GlDropdownItem } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
...@@ -8,8 +8,6 @@ import { createStore } from '~/monitoring/stores'; ...@@ -8,8 +8,6 @@ import { createStore } from '~/monitoring/stores';
import { propsData, setupComponentStore } from '../init_utils'; import { propsData, setupComponentStore } from '../init_utils';
import { metricsGroupsAPIResponse, mockApiEndpoint } from '../mock_data'; import { metricsGroupsAPIResponse, mockApiEndpoint } from '../mock_data';
const localVue = createLocalVue();
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
getParameterValues: jest.fn().mockImplementation(param => { getParameterValues: jest.fn().mockImplementation(param => {
if (param === 'start') return ['2019-10-01T18:27:47.000Z']; if (param === 'start') return ['2019-10-01T18:27:47.000Z'];
...@@ -25,8 +23,7 @@ describe('dashboard time window', () => { ...@@ -25,8 +23,7 @@ describe('dashboard time window', () => {
let mock; let mock;
const createComponentWrapperMounted = (props = {}, options = {}) => { const createComponentWrapperMounted = (props = {}, options = {}) => {
wrapper = mount(localVue.extend(Dashboard), { wrapper = mount(Dashboard, {
localVue,
sync: false, sync: false,
propsData: { ...propsData, ...props }, propsData: { ...propsData, ...props },
store, store,
......
...@@ -3,10 +3,8 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p ...@@ -3,10 +3,8 @@ import DateTimePicker from '~/monitoring/components/date_time_picker/date_time_p
import { timeWindows } from '~/monitoring/constants'; import { timeWindows } from '~/monitoring/constants';
const timeWindowsCount = Object.keys(timeWindows).length; const timeWindowsCount = Object.keys(timeWindows).length;
const selectedTimeWindow = { const start = '2019-10-10T07:00:00.000Z';
start: '2019-10-10T07:00:00.000Z', const end = '2019-10-13T07:00:00.000Z';
end: '2019-10-13T07:00:00.000Z',
};
const selectedTimeWindowText = `3 days`; const selectedTimeWindowText = `3 days`;
describe('DateTimePicker', () => { describe('DateTimePicker', () => {
...@@ -28,7 +26,8 @@ describe('DateTimePicker', () => { ...@@ -28,7 +26,8 @@ describe('DateTimePicker', () => {
dateTimePicker = mount(DateTimePicker, { dateTimePicker = mount(DateTimePicker, {
propsData: { propsData: {
timeWindows, timeWindows,
selectedTimeWindow, start,
end,
...props, ...props,
}, },
sync: false, sync: false,
...@@ -66,10 +65,8 @@ describe('DateTimePicker', () => { ...@@ -66,10 +65,8 @@ describe('DateTimePicker', () => {
it('renders inputs with h/m/s truncated if its all 0s', done => { it('renders inputs with h/m/s truncated if its all 0s', done => {
createComponent({ createComponent({
selectedTimeWindow: { start: '2019-10-10T00:00:00.000Z',
start: '2019-10-10T00:00:00.000Z', end: '2019-10-14T00:10:00.000Z',
end: '2019-10-14T00:10:00.000Z',
},
}); });
dateTimePicker.vm.$nextTick(() => { dateTimePicker.vm.$nextTick(() => {
expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10'); expect(dateTimePicker.find('#custom-time-from').element.value).toBe('2019-10-10');
...@@ -98,8 +95,10 @@ describe('DateTimePicker', () => { ...@@ -98,8 +95,10 @@ describe('DateTimePicker', () => {
}); });
}); });
it('renders a disabled apply button on load', () => { it('renders a disabled apply button on wrong input', () => {
createComponent(); createComponent({
start: 'invalid-input-date',
});
expect(applyButtonElement().getAttribute('disabled')).toBe('disabled'); expect(applyButtonElement().getAttribute('disabled')).toBe('disabled');
}); });
...@@ -131,29 +130,29 @@ describe('DateTimePicker', () => { ...@@ -131,29 +130,29 @@ describe('DateTimePicker', () => {
fillInputAndBlur('#custom-time-from', '2019-10-01') fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => { .then(() => {
dateTimePicker.vm.$nextTick(() => { expect(applyButtonElement().getAttribute('disabled')).toBeNull();
expect(applyButtonElement().getAttribute('disabled')).toBeNull(); done();
done();
});
}) })
.catch(done); .catch(done.fail);
}); });
it('returns an object when apply is clicked', done => { it('emits dates in an object when apply is clicked', done => {
createComponent(); createComponent();
fillInputAndBlur('#custom-time-from', '2019-10-01') fillInputAndBlur('#custom-time-from', '2019-10-01')
.then(() => fillInputAndBlur('#custom-time-to', '2019-10-19')) .then(() => fillInputAndBlur('#custom-time-to', '2019-10-19'))
.then(() => { .then(() => {
jest.spyOn(dateTimePicker.vm, '$emit');
applyButtonElement().click(); applyButtonElement().click();
expect(dateTimePicker.vm.$emit).toHaveBeenCalledWith('onApply', { expect(dateTimePicker.emitted().apply).toHaveLength(1);
end: '2019-10-19T00:00:00Z', expect(dateTimePicker.emitted().apply[0]).toEqual([
start: '2019-10-01T00:00:00Z', {
}); end: '2019-10-19T00:00:00Z',
start: '2019-10-01T00:00:00Z',
},
]);
done(); done();
}) })
.catch(done); .catch(done.fail);
}); });
it('hides the popover with cancel button', done => { it('hides the popover with cancel button', done => {
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import GraphGroup from '~/monitoring/components/graph_group.vue'; import GraphGroup from '~/monitoring/components/graph_group.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
const localVue = createLocalVue();
describe('Graph group component', () => { describe('Graph group component', () => {
let wrapper; let wrapper;
...@@ -12,10 +10,9 @@ describe('Graph group component', () => { ...@@ -12,10 +10,9 @@ describe('Graph group component', () => {
const findCaretIcon = () => wrapper.find(Icon); const findCaretIcon = () => wrapper.find(Icon);
const createComponent = propsData => { const createComponent = propsData => {
wrapper = shallowMount(localVue.extend(GraphGroup), { wrapper = shallowMount(GraphGroup, {
propsData, propsData,
sync: false, sync: false,
localVue,
}); });
}; };
......
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Callout from '~/vue_shared/components/callout.vue'; import Callout from '~/vue_shared/components/callout.vue';
const TEST_MESSAGE = 'This is a callout message!'; const TEST_MESSAGE = 'This is a callout message!';
const TEST_SLOT = '<button>This is a callout slot!</button>'; const TEST_SLOT = '<button>This is a callout slot!</button>';
const localVue = createLocalVue();
describe('Callout Component', () => { describe('Callout Component', () => {
let wrapper; let wrapper;
const factory = options => { const factory = options => {
wrapper = shallowMount(localVue.extend(Callout), { wrapper = shallowMount(Callout, {
localVue,
...options, ...options,
}); });
}; };
......
import Vue from 'vue'; import Vue from 'vue';
import { mount, createLocalVue } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ExpandButton from '~/vue_shared/components/expand_button.vue'; import ExpandButton from '~/vue_shared/components/expand_button.vue';
const text = { const text = {
...@@ -14,10 +14,7 @@ describe('Expand button', () => { ...@@ -14,10 +14,7 @@ describe('Expand button', () => {
const expanderAppendEl = () => wrapper.find('.js-text-expander-append'); const expanderAppendEl = () => wrapper.find('.js-text-expander-append');
const factory = (options = {}) => { const factory = (options = {}) => {
const localVue = createLocalVue(); wrapper = mount(ExpandButton, {
wrapper = mount(localVue.extend(ExpandButton), {
localVue,
...options, ...options,
}); });
}; };
......
import Vue from 'vue'; import Vue from 'vue';
import { mount, createLocalVue } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
import { import {
...@@ -29,10 +29,7 @@ describe('RelatedIssuableItem', () => { ...@@ -29,10 +29,7 @@ describe('RelatedIssuableItem', () => {
}; };
beforeEach(() => { beforeEach(() => {
const localVue = createLocalVue(); wrapper = mount(RelatedIssuableItem, {
wrapper = mount(localVue.extend(RelatedIssuableItem), {
localVue,
slots, slots,
sync: false, sync: false,
attachToDocument: true, attachToDocument: true,
......
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue'; import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
const localVue = createLocalVue();
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
canApply: true, canApply: true,
isApplied: false, isApplied: false,
...@@ -14,12 +12,11 @@ describe('Suggestion Diff component', () => { ...@@ -14,12 +12,11 @@ describe('Suggestion Diff component', () => {
let wrapper; let wrapper;
const createComponent = props => { const createComponent = props => {
wrapper = shallowMount(localVue.extend(SuggestionDiffHeader), { wrapper = shallowMount(SuggestionDiffHeader, {
propsData: { propsData: {
...DEFAULT_PROPS, ...DEFAULT_PROPS,
...props, ...props,
}, },
localVue,
sync: false, sync: false,
attachToDocument: true, attachToDocument: true,
}); });
......
import { createLocalVue, mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue'; import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue';
import createStore from '~/notes/stores'; import createStore from '~/notes/stores';
import initMRPopovers from '~/mr_popover/index'; import initMRPopovers from '~/mr_popover/index';
jest.mock('~/mr_popover/index', () => jest.fn()); jest.mock('~/mr_popover/index', () => jest.fn());
const localVue = createLocalVue();
describe('system note component', () => { describe('system note component', () => {
let vm; let vm;
let props; let props;
...@@ -34,7 +32,6 @@ describe('system note component', () => { ...@@ -34,7 +32,6 @@ describe('system note component', () => {
vm = mount(IssueSystemNote, { vm = mount(IssueSystemNote, {
store, store,
localVue,
propsData: props, propsData: props,
attachToDocument: true, attachToDocument: true,
sync: false, sync: false,
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
describe(`TimelineEntryItem`, () => { describe(`TimelineEntryItem`, () => {
let wrapper; let wrapper;
const factory = (options = {}) => { const factory = (options = {}) => {
const localVue = createLocalVue();
wrapper = shallowMount(TimelineEntryItem, { wrapper = shallowMount(TimelineEntryItem, {
localVue,
...options, ...options,
}); });
}; };
......
import { mount, createLocalVue } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui'; import { GlPagination } from '@gitlab/ui';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import { import {
...@@ -10,8 +10,6 @@ import { ...@@ -10,8 +10,6 @@ import {
LABEL_LAST_PAGE, LABEL_LAST_PAGE,
} from '~/vue_shared/components/pagination/constants'; } from '~/vue_shared/components/pagination/constants';
const localVue = createLocalVue();
describe('Pagination links component', () => { describe('Pagination links component', () => {
const pageInfo = { const pageInfo = {
page: 3, page: 3,
...@@ -38,7 +36,6 @@ describe('Pagination links component', () => { ...@@ -38,7 +36,6 @@ describe('Pagination links component', () => {
change: changeMock, change: changeMock,
pageInfo, pageInfo,
}, },
localVue,
sync: false, sync: false,
}); });
}; };
......
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
...@@ -10,7 +10,6 @@ describe('Time ago with tooltip component', () => { ...@@ -10,7 +10,6 @@ describe('Time ago with tooltip component', () => {
attachToDocument: true, attachToDocument: true,
sync: false, sync: false,
propsData, propsData,
localVue: createLocalVue(),
}); });
}; };
const timestamp = '2017-05-08T14:57:39.781Z'; const timestamp = '2017-05-08T14:57:39.781Z';
......
import Vue from 'vue'; import Vue from 'vue';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import TrackEvent from '~/vue_shared/directives/track_event'; import TrackEvent from '~/vue_shared/directives/track_event';
...@@ -17,15 +17,12 @@ const Component = Vue.component('dummy-element', { ...@@ -17,15 +17,12 @@ const Component = Vue.component('dummy-element', {
template: '<button id="trackable" v-track-event="trackingOptions"></button>', template: '<button id="trackable" v-track-event="trackingOptions"></button>',
}); });
const localVue = createLocalVue();
let wrapper; let wrapper;
let button; let button;
describe('Error Tracking directive', () => { describe('Error Tracking directive', () => {
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(localVue.extend(Component), { wrapper = shallowMount(Component, {});
localVue,
});
button = wrapper.find('#trackable'); button = wrapper.find('#trackable');
}); });
......
import { mount, createLocalVue } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue'; import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue';
...@@ -18,11 +18,8 @@ const createComponent = ({ ...@@ -18,11 +18,8 @@ const createComponent = ({
dropdownClass = '', dropdownClass = '',
actions = mockActions, actions = mockActions,
defaultAction = 0, defaultAction = 0,
}) => { }) =>
const localVue = createLocalVue(); mount(DroplabDropdownButton, {
return mount(DroplabDropdownButton, {
localVue,
propsData: { propsData: {
size, size,
dropdownClass, dropdownClass,
...@@ -30,7 +27,6 @@ const createComponent = ({ ...@@ -30,7 +27,6 @@ const createComponent = ({
defaultAction, defaultAction,
}, },
}); });
};
describe('DroplabDropdownButton', () => { describe('DroplabDropdownButton', () => {
let wrapper; let wrapper;
......
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const localVue = createLocalVue();
describe('GitLab Feature Flags Mixin', () => { describe('GitLab Feature Flags Mixin', () => {
let wrapper; let wrapper;
...@@ -20,7 +18,6 @@ describe('GitLab Feature Flags Mixin', () => { ...@@ -20,7 +18,6 @@ describe('GitLab Feature Flags Mixin', () => {
}; };
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
localVue,
provide: { provide: {
glFeatures: { ...(gon.features || {}) }, glFeatures: { ...(gon.features || {}) },
}, },
......
...@@ -39,17 +39,17 @@ describe('dropzone_input', () => { ...@@ -39,17 +39,17 @@ describe('dropzone_input', () => {
const event = $.Event('paste'); const event = $.Event('paste');
const origEvent = new Event('paste'); const origEvent = new Event('paste');
const pasteData = new DataTransfer(); const pasteData = new DataTransfer();
pasteData.setData('text/plain', 'hello world'); pasteData.setData('text/plain', 'Hello World');
pasteData.setData('text/html', '<table></table>'); pasteData.setData('text/html', '<table><tr><td>Hello World</td></tr></table>');
origEvent.clipboardData = pasteData; origEvent.clipboardData = pasteData;
event.originalEvent = origEvent; event.originalEvent = origEvent;
spyOn(PasteMarkdownTable, 'isTable').and.callThrough(); spyOn(PasteMarkdownTable.prototype, 'isTable').and.callThrough();
spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown').and.callThrough(); spyOn(PasteMarkdownTable.prototype, 'convertToTableMarkdown').and.callThrough();
$('.js-gfm-input').trigger(event); $('.js-gfm-input').trigger(event);
expect(PasteMarkdownTable.isTable).toHaveBeenCalled(); expect(PasteMarkdownTable.prototype.isTable).toHaveBeenCalled();
expect(PasteMarkdownTable.prototype.convertToTableMarkdown).toHaveBeenCalled(); expect(PasteMarkdownTable.prototype.convertToTableMarkdown).toHaveBeenCalled();
}); });
}); });
......
...@@ -17,8 +17,8 @@ describe Gitlab::UsageData do ...@@ -17,8 +17,8 @@ describe Gitlab::UsageData do
create(:service, project: projects[0], type: 'SlackSlashCommandsService', active: true) create(:service, project: projects[0], type: 'SlackSlashCommandsService', active: true)
create(:service, project: projects[1], type: 'SlackService', active: true) create(:service, project: projects[1], type: 'SlackService', active: true)
create(:service, project: projects[2], type: 'SlackService', active: true) create(:service, project: projects[2], type: 'SlackService', active: true)
create(:service, project: projects[2], type: 'MattermostService', active: true) create(:service, project: projects[2], type: 'MattermostService', active: false)
create(:service, project: projects[2], type: 'JenkinsService', active: true) create(:service, project: projects[2], type: 'MattermostService', active: true, template: true)
create(:service, project: projects[2], type: 'CustomIssueTrackerService', active: true) create(:service, project: projects[2], type: 'CustomIssueTrackerService', active: true)
create(:project_error_tracking_setting, project: projects[0]) create(:project_error_tracking_setting, project: projects[0])
create(:project_error_tracking_setting, project: projects[1], enabled: false) create(:project_error_tracking_setting, project: projects[1], enabled: false)
...@@ -168,13 +168,15 @@ describe Gitlab::UsageData do ...@@ -168,13 +168,15 @@ describe Gitlab::UsageData do
pool_repositories pool_repositories
projects projects
projects_imported_from_github projects_imported_from_github
projects_asana_active
projects_jira_active projects_jira_active
projects_jira_server_active projects_jira_server_active
projects_jira_cloud_active projects_jira_cloud_active
projects_slack_notifications_active projects_slack_notifications_active
projects_slack_slash_active projects_slack_slash_active
projects_slack_active
projects_slack_slash_commands_active
projects_custom_issue_tracker_active projects_custom_issue_tracker_active
projects_jenkins_active
projects_mattermost_active projects_mattermost_active
projects_prometheus_active projects_prometheus_active
projects_with_repositories_enabled projects_with_repositories_enabled
...@@ -203,15 +205,17 @@ describe Gitlab::UsageData do ...@@ -203,15 +205,17 @@ describe Gitlab::UsageData do
count_data = subject[:counts] count_data = subject[:counts]
expect(count_data[:projects]).to eq(4) expect(count_data[:projects]).to eq(4)
expect(count_data[:projects_asana_active]).to eq(0)
expect(count_data[:projects_prometheus_active]).to eq(1) expect(count_data[:projects_prometheus_active]).to eq(1)
expect(count_data[:projects_jira_active]).to eq(4) expect(count_data[:projects_jira_active]).to eq(4)
expect(count_data[:projects_jira_server_active]).to eq(2) expect(count_data[:projects_jira_server_active]).to eq(2)
expect(count_data[:projects_jira_cloud_active]).to eq(2) expect(count_data[:projects_jira_cloud_active]).to eq(2)
expect(count_data[:projects_slack_notifications_active]).to eq(2) expect(count_data[:projects_slack_notifications_active]).to eq(2)
expect(count_data[:projects_slack_slash_active]).to eq(1) expect(count_data[:projects_slack_slash_active]).to eq(1)
expect(count_data[:projects_slack_active]).to eq(2)
expect(count_data[:projects_slack_slash_commands_active]).to eq(1)
expect(count_data[:projects_custom_issue_tracker_active]).to eq(1) expect(count_data[:projects_custom_issue_tracker_active]).to eq(1)
expect(count_data[:projects_jenkins_active]).to eq(1) expect(count_data[:projects_mattermost_active]).to eq(0)
expect(count_data[:projects_mattermost_active]).to eq(1)
expect(count_data[:projects_with_repositories_enabled]).to eq(3) expect(count_data[:projects_with_repositories_enabled]).to eq(3)
expect(count_data[:projects_with_error_tracking_enabled]).to eq(1) expect(count_data[:projects_with_error_tracking_enabled]).to eq(1)
expect(count_data[:issues_created_from_gitlab_error_tracking_ui]).to eq(1) expect(count_data[:issues_created_from_gitlab_error_tracking_ui]).to eq(1)
......
...@@ -8,6 +8,216 @@ describe Sentry::Client::Issue do ...@@ -8,6 +8,216 @@ describe Sentry::Client::Issue do
let(:token) { 'test-token' } let(:token) { 'test-token' }
let(:client) { Sentry::Client.new(sentry_url, token) } let(:client) { Sentry::Client.new(sentry_url, token) }
describe '#list_issues' do
shared_examples 'issues have correct return type' do |klass|
it "returns objects of type #{klass}" do
expect(subject[:issues]).to all( be_a(klass) )
end
end
shared_examples 'issues have correct length' do |length|
it { expect(subject[:issues].length).to eq(length) }
end
let(:issues_sample_response) do
Gitlab::Utils.deep_indifferent_access(
JSON.parse(fixture_file('sentry/issues_sample_response.json'))
)
end
let(:default_httparty_options) do
{
follow_redirects: false,
headers: { "Authorization" => "Bearer test-token" }
}
end
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:issue_status) { 'unresolved' }
let(:limit) { 20 }
let(:search_term) { '' }
let(:cursor) { nil }
let(:sort) { 'last_seen' }
let(:sentry_api_response) { issues_sample_response }
let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
shared_examples 'has correct external_url' do
context 'external_url' do
it 'is constructed correctly' do
expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11')
end
end
end
context 'when response has a pagination info' do
let(:headers) do
{
link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1", <https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"'
}
end
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response, headers: headers) }
it 'parses the pagination' do
expect(subject[:pagination]).to eq(
'previous' => { 'cursor' => '1573556671000:0:1' },
'next' => { 'cursor' => '1572959139000:0:0' }
)
end
end
context 'error object created from sentry response' do
using RSpec::Parameterized::TableSyntax
where(:error_object, :sentry_response) do
:id | :id
:first_seen | :firstSeen
:last_seen | :lastSeen
:title | :title
:type | :type
:user_count | :userCount
:count | :count
:message | [:metadata, :value]
:culprit | :culprit
:short_id | :shortId
:status | :status
:frequency | [:stats, '24h']
:project_id | [:project, :id]
:project_name | [:project, :name]
:project_slug | [:project, :slug]
end
with_them do
it { expect(subject[:issues][0].public_send(error_object)).to eq(sentry_api_response[0].dig(*sentry_response)) }
end
it_behaves_like 'has correct external_url'
end
context 'redirects' do
let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
it_behaves_like 'no Sentry redirects'
end
# Sentry API returns 404 if there are extra slashes in the URL!
context 'extra slashes in URL' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects//sentry-org/sentry-project/' }
let(:sentry_request_url) do
'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved'
end
it 'removes extra slashes in api url' do
expect(client.url).to eq(sentry_url)
expect(Gitlab::HTTP).to receive(:get).with(
URI('https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/issues/'),
anything
).and_call_original
subject
expect(sentry_api_request).to have_been_requested
end
end
context 'requests with sort parameter in sentry api' do
let(:sentry_request_url) do
'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved&sort=freq'
end
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'frequency') }
it 'calls the sentry api with sort params' do
expect(Gitlab::HTTP).to receive(:get).with(
URI("#{sentry_url}/issues/"),
default_httparty_options.merge(query: { limit: 20, query: "is:unresolved", sort: "freq" })
).and_call_original
subject
expect(sentry_api_request).to have_been_requested
end
end
context 'with invalid sort params' do
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'fish') }
it 'throws an error' do
expect { subject }.to raise_error(Sentry::Client::BadRequestError, 'Invalid value for sort param')
end
end
context 'Older sentry versions where keys are not present' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue[:project].delete(:id)
issue
end
end
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
it_behaves_like 'has correct external_url'
end
context 'essential keys missing in API response' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue.except(:id)
end
end
it 'raises exception' do
expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"')
end
end
context 'sentry api response too large' do
it 'raises exception' do
deep_size = double('Gitlab::Utils::DeepSize', valid?: false)
allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size)
expect { subject }.to raise_error(Sentry::Client::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.')
end
end
it_behaves_like 'maps Sentry exceptions'
context 'when search term is present' do
let(:search_term) { 'NoMethodError' }
let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved NoMethodError" }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
end
context 'when cursor is present' do
let(:cursor) { '1572959139000:0:0' }
let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&cursor=#{cursor}&query=is:unresolved" }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues have correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues have correct length', 1
end
end
describe '#issue_details' do describe '#issue_details' do
let(:issue_sample_response) do let(:issue_sample_response) do
Gitlab::Utils.deep_indifferent_access( Gitlab::Utils.deep_indifferent_access(
......
...@@ -3,219 +3,13 @@ ...@@ -3,219 +3,13 @@
require 'spec_helper' require 'spec_helper'
describe Sentry::Client do describe Sentry::Client do
include SentryClientHelpers
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' } let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project' }
let(:token) { 'test-token' } let(:token) { 'test-token' }
let(:default_httparty_options) do
{
follow_redirects: false,
headers: { "Authorization" => "Bearer test-token" }
}
end
subject(:client) { described_class.new(sentry_url, token) }
shared_examples 'issues has correct return type' do |klass|
it "returns objects of type #{klass}" do
expect(subject[:issues]).to all( be_a(klass) )
end
end
shared_examples 'issues has correct length' do |length|
it { expect(subject[:issues].length).to eq(length) }
end
describe '#list_issues' do
let(:issues_sample_response) do
Gitlab::Utils.deep_indifferent_access(
JSON.parse(fixture_file('sentry/issues_sample_response.json'))
)
end
let(:issue_status) { 'unresolved' }
let(:limit) { 20 }
let(:search_term) { '' }
let(:cursor) { nil }
let(:sort) { 'last_seen' }
let(:sentry_api_response) { issues_sample_response }
let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues has correct length', 1
shared_examples 'has correct external_url' do
context 'external_url' do
it 'is constructed correctly' do
expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11')
end
end
end
context 'when response has a pagination info' do
let(:headers) do
{
link: '<https://sentrytest.gitlab.com>; rel="previous"; results="true"; cursor="1573556671000:0:1", <https://sentrytest.gitlab.com>; rel="next"; results="true"; cursor="1572959139000:0:0"'
}
end
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response, headers: headers) }
it 'parses the pagination' do
expect(subject[:pagination]).to eq(
'previous' => { 'cursor' => '1573556671000:0:1' },
'next' => { 'cursor' => '1572959139000:0:0' }
)
end
end
context 'error object created from sentry response' do
using RSpec::Parameterized::TableSyntax
where(:error_object, :sentry_response) do
:id | :id
:first_seen | :firstSeen
:last_seen | :lastSeen
:title | :title
:type | :type
:user_count | :userCount
:count | :count
:message | [:metadata, :value]
:culprit | :culprit
:short_id | :shortId
:status | :status
:frequency | [:stats, '24h']
:project_id | [:project, :id]
:project_name | [:project, :name]
:project_slug | [:project, :slug]
end
with_them do
it { expect(subject[:issues][0].public_send(error_object)).to eq(sentry_api_response[0].dig(*sentry_response)) }
end
it_behaves_like 'has correct external_url'
end
context 'redirects' do
let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' }
it_behaves_like 'no Sentry redirects'
end
# Sentry API returns 404 if there are extra slashes in the URL!
context 'extra slashes in URL' do
let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0/projects//sentry-org/sentry-project/' }
let(:sentry_request_url) do
'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved'
end
it 'removes extra slashes in api url' do
expect(client.url).to eq(sentry_url)
expect(Gitlab::HTTP).to receive(:get).with(
URI('https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/issues/'),
anything
).and_call_original
subject
expect(sentry_api_request).to have_been_requested
end
end
context 'requests with sort parameter in sentry api' do
let(:sentry_request_url) do
'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \
'issues/?limit=20&query=is:unresolved&sort=freq'
end
let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) }
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'frequency') }
it 'calls the sentry api with sort params' do
expect(Gitlab::HTTP).to receive(:get).with(
URI("#{sentry_url}/issues/"),
default_httparty_options.merge(query: { limit: 20, query: "is:unresolved", sort: "freq" })
).and_call_original
subject
expect(sentry_api_request).to have_been_requested
end
end
context 'with invalid sort params' do
subject { client.list_issues(issue_status: issue_status, limit: limit, sort: 'fish') }
it 'throws an error' do
expect { subject }.to raise_error(Sentry::Client::BadRequestError, 'Invalid value for sort param')
end
end
context 'Older sentry versions where keys are not present' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue[:project].delete(:id)
issue
end
end
it_behaves_like 'calls sentry api'
it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues has correct length', 1
it_behaves_like 'has correct external_url'
end
context 'essential keys missing in API response' do
let(:sentry_api_response) do
issues_sample_response[0...1].map do |issue|
issue.except(:id)
end
end
it 'raises exception' do
expect { subject }.to raise_error(Sentry::Client::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"')
end
end
context 'sentry api response too large' do
it 'raises exception' do
deep_size = double('Gitlab::Utils::DeepSize', valid?: false)
allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size)
expect { subject }.to raise_error(Sentry::Client::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.')
end
end
it_behaves_like 'maps Sentry exceptions'
context 'when search term is present' do
let(:search_term) { 'NoMethodError' }
let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved NoMethodError" }
it_behaves_like 'calls sentry api'
it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error
it_behaves_like 'issues has correct length', 1
end
context 'when cursor is present' do
let(:cursor) { '1572959139000:0:0' }
let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&cursor=#{cursor}&query=is:unresolved" }
it_behaves_like 'calls sentry api' subject { Sentry::Client.new(sentry_url, token) }
it_behaves_like 'issues has correct return type', Gitlab::ErrorTracking::Error it { is_expected.to respond_to :projects }
it_behaves_like 'issues has correct length', 1 it { is_expected.to respond_to :list_issues }
end it { is_expected.to respond_to :issue_details }
end it { is_expected.to respond_to :issue_latest_event }
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment