Skip to content
Snippets Groups Projects
Commit 32fd4cd5 authored by GitLab Bot's avatar GitLab Bot
Browse files

Add latest changes from gitlab-org/gitlab@master

parent 951616a2
No related branches found
No related tags found
No related merge requests found
Showing
with 516 additions and 278 deletions
Loading
Loading
@@ -19,6 +19,9 @@ module Gitlab
class JobWaiter
KEY_PREFIX = "gitlab:job_waiter"
 
STARTED_METRIC = :gitlab_job_waiter_started_total
TIMEOUTS_METRIC = :gitlab_job_waiter_timeouts_total
def self.notify(key, jid)
Gitlab::Redis::SharedState.with { |redis| redis.lpush(key, jid) }
end
Loading
Loading
@@ -27,15 +30,16 @@ module Gitlab
key.is_a?(String) && key =~ /\A#{KEY_PREFIX}:\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
end
 
attr_reader :key, :finished
attr_reader :key, :finished, :worker_label
attr_accessor :jobs_remaining
 
# jobs_remaining - the number of jobs left to wait for
# key - The key of this waiter.
def initialize(jobs_remaining = 0, key = "#{KEY_PREFIX}:#{SecureRandom.uuid}")
def initialize(jobs_remaining = 0, key = "#{KEY_PREFIX}:#{SecureRandom.uuid}", worker_label: nil)
@key = key
@jobs_remaining = jobs_remaining
@finished = []
@worker_label = worker_label
end
 
# Waits for all the jobs to be completed.
Loading
Loading
@@ -45,6 +49,7 @@ module Gitlab
# long to process, or is never processed.
def wait(timeout = 10)
deadline = Time.now.utc + timeout
increment_counter(STARTED_METRIC)
 
Gitlab::Redis::SharedState.with do |redis|
# Fallback key expiry: allow a long grace period to reduce the chance of
Loading
Loading
@@ -60,7 +65,12 @@ module Gitlab
break if seconds_left <= 0
 
list, jid = redis.blpop(key, timeout: seconds_left)
break unless list && jid # timed out
# timed out
unless list && jid
increment_counter(TIMEOUTS_METRIC)
break
end
 
@finished << jid
@jobs_remaining -= 1
Loading
Loading
@@ -72,5 +82,20 @@ module Gitlab
 
finished
end
private
def increment_counter(metric)
return unless worker_label
metrics[metric].increment(worker: worker_label)
end
def metrics
@metrics ||= {
STARTED_METRIC => Gitlab::Metrics.counter(STARTED_METRIC, 'JobWaiter attempts started'),
TIMEOUTS_METRIC => Gitlab::Metrics.counter(TIMEOUTS_METRIC, 'JobWaiter attempts timed out')
}
end
end
end
Loading
Loading
@@ -63,7 +63,7 @@ module Gitlab
 
# Convert Markdown to slacks format
def format(string)
Slack::Notifier::LinkFormatter.format(string)
Slack::Messenger::Util::LinkFormatter.format(string)
end
 
def resource_url
Loading
Loading
# frozen_string_literal: true
module Gitlab
module Tracing
# Only enable tracing when the `GITLAB_TRACING` env var is configured. Note that we avoid using ApplicationSettings since
# the same environment variable needs to be configured for Workhorse, Gitaly and any other components which
# emit tracing. Since other components may start before Rails, and may not have access to ApplicationSettings,
# an env var makes more sense.
def self.enabled?
connection_string.present?
end
def self.connection_string
ENV['GITLAB_TRACING']
end
def self.tracing_url_template
ENV['GITLAB_TRACING_URL']
end
def self.tracing_url_enabled?
enabled? && tracing_url_template.present?
end
# This will provide a link into the distributed tracing for the current trace,
# if it has been captured.
def self.tracing_url
return unless tracing_url_enabled?
# Avoid using `format` since it can throw TypeErrors
# which we want to avoid on unsanitised env var input
tracing_url_template.to_s
.gsub(/\{\{\s*correlation_id\s*\}\}/, Labkit::Correlation::CorrelationId.current_id.to_s)
.gsub(/\{\{\s*service\s*\}\}/, Gitlab.process_name)
end
end
end
Loading
Loading
@@ -17847,9 +17847,27 @@ msgstr ""
msgid "Slack application"
msgstr ""
 
msgid "Slack channels (e.g. general, development)"
msgstr ""
msgid "Slack integration allows you to interact with GitLab via slash commands in a chat window."
msgstr ""
 
msgid "SlackIntegration|%{webhooks_link_start}Add an incoming webhook%{webhooks_link_end} in your Slack team. The default channel can be overridden for each event."
msgstr ""
msgid "SlackIntegration|<strong>Note:</strong> Usernames and private channels are not supported."
msgstr ""
msgid "SlackIntegration|Paste the <strong>Webhook URL</strong> into the field below."
msgstr ""
msgid "SlackIntegration|Select events below to enable notifications. The <strong>Slack channel names</strong> and <strong>Slack username</strong> fields are optional."
msgstr ""
msgid "SlackIntegration|This service send notifications about projects' events to Slack channels. To set up this service:"
msgstr ""
msgid "SlackService|2. Paste the <strong>Token</strong> into the field below"
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -4,12 +4,33 @@ require 'spec_helper'
 
describe Explore::SnippetsController do
describe 'GET #index' do
it_behaves_like 'paginated collection' do
let(:collection) { Snippet.all }
let!(:project_snippet) { create_list(:project_snippet, 3, :public) }
let!(:personal_snippet) { create_list(:personal_snippet, 3, :public) }
 
before do
create(:personal_snippet, :public)
end
before do
allow(Kaminari.config).to receive(:default_per_page).and_return(2)
end
it 'renders' do
get :index
snippets = assigns(:snippets)
expect(snippets).to be_a(::Kaminari::PaginatableWithoutCount)
expect(snippets.size).to eq(2)
expect(snippets).to all(be_a(PersonalSnippet))
expect(response).to have_gitlab_http_status(:ok)
end
it 'renders pagination' do
get :index, params: { page: 2 }
snippets = assigns(:snippets)
expect(snippets).to be_a(::Kaminari::PaginatableWithoutCount)
expect(snippets.size).to eq(1)
expect(assigns(:snippets)).to all(be_a(PersonalSnippet))
expect(response).to have_gitlab_http_status(:ok)
end
end
end
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
import store from '~/badges/store';
import createEmptyBadge from '~/badges/empty_badge';
import BadgeForm from '~/badges/components/badge_form.vue';
import { DUMMY_IMAGE_URL, TEST_HOST } from '../../test_constants';
import { DUMMY_IMAGE_URL, TEST_HOST } from 'helpers/test_constants';
 
// avoid preview background process
BadgeForm.methods.debouncedPreview = () => {};
Loading
Loading
@@ -41,7 +41,7 @@ describe('BadgeForm component', () => {
 
describe('onCancel', () => {
it('calls stopEditing', () => {
spyOn(vm, 'stopEditing');
jest.spyOn(vm, 'stopEditing').mockImplementation(() => {});
 
vm.onCancel();
 
Loading
Loading
@@ -68,14 +68,14 @@ describe('BadgeForm component', () => {
const expectInvalidInput = inputElementSelector => {
const inputElement = vm.$el.querySelector(inputElementSelector);
 
expect(inputElement).toBeMatchedBy(':invalid');
expect(inputElement.checkValidity()).toBe(false);
const feedbackElement = vm.$el.querySelector(`${inputElementSelector} + .invalid-feedback`);
 
expect(feedbackElement).toBeVisible();
};
 
beforeEach(() => {
spyOn(vm, submitAction).and.returnValue(Promise.resolve());
beforeEach(done => {
jest.spyOn(vm, submitAction).mockReturnValue(Promise.resolve());
store.replaceState({
...store.state,
badgeInAddForm: createEmptyBadge(),
Loading
Loading
@@ -83,9 +83,14 @@ describe('BadgeForm component', () => {
isSaving: false,
});
 
setValue(nameSelector, 'TestBadge');
setValue(linkUrlSelector, `${TEST_HOST}/link/url`);
setValue(imageUrlSelector, `${window.location.origin}${DUMMY_IMAGE_URL}`);
Vue.nextTick()
.then(() => {
setValue(nameSelector, 'TestBadge');
setValue(linkUrlSelector, `${TEST_HOST}/link/url`);
setValue(imageUrlSelector, `${window.location.origin}${DUMMY_IMAGE_URL}`);
})
.then(done)
.catch(done.fail);
});
 
it('returns immediately if imageUrl is empty', () => {
Loading
Loading
@@ -131,8 +136,8 @@ describe('BadgeForm component', () => {
it(`calls ${submitAction}`, () => {
submitForm();
 
expect(findImageUrlElement()).toBeMatchedBy(':valid');
expect(findLinkUrlElement()).toBeMatchedBy(':valid');
expect(findImageUrlElement().checkValidity()).toBe(true);
expect(findLinkUrlElement().checkValidity()).toBe(true);
expect(vm[submitAction]).toHaveBeenCalled();
});
};
Loading
Loading
import $ from 'jquery';
import Vue from 'vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
import store from '~/badges/store';
import BadgeListRow from '~/badges/components/badge_list_row.vue';
Loading
Loading
@@ -40,15 +39,15 @@ describe('BadgeListRow component', () => {
});
 
it('renders the badge name', () => {
expect(vm.$el).toContainText(badge.name);
expect(vm.$el.innerText).toMatch(badge.name);
});
 
it('renders the badge link', () => {
expect(vm.$el).toContainText(badge.linkUrl);
expect(vm.$el.innerText).toMatch(badge.linkUrl);
});
 
it('renders the badge kind', () => {
expect(vm.$el).toContainText('Project Badge');
expect(vm.$el.innerText).toMatch('Project Badge');
});
 
it('shows edit and delete buttons', () => {
Loading
Loading
@@ -66,7 +65,7 @@ describe('BadgeListRow component', () => {
});
 
it('calls editBadge when clicking then edit button', () => {
spyOn(vm, 'editBadge');
jest.spyOn(vm, 'editBadge').mockImplementation(() => {});
 
const editButton = vm.$el.querySelector('.table-button-footer button:first-of-type');
editButton.click();
Loading
Loading
@@ -75,13 +74,17 @@ describe('BadgeListRow component', () => {
});
 
it('calls updateBadgeInModal and shows modal when clicking then delete button', done => {
spyOn(vm, 'updateBadgeInModal');
$('#delete-badge-modal').on('shown.bs.modal', () => done());
jest.spyOn(vm, 'updateBadgeInModal').mockImplementation(() => {});
 
const deleteButton = vm.$el.querySelector('.table-button-footer button:last-of-type');
deleteButton.click();
 
expect(vm.updateBadgeInModal).toHaveBeenCalled();
Vue.nextTick()
.then(() => {
expect(vm.updateBadgeInModal).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
 
describe('for a group badge', () => {
Loading
Loading
@@ -94,7 +97,7 @@ describe('BadgeListRow component', () => {
});
 
it('renders the badge kind', () => {
expect(vm.$el).toContainText('Group Badge');
expect(vm.$el.innerText).toMatch('Group Badge');
});
 
it('hides edit and delete buttons', () => {
Loading
Loading
import Vue from 'vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
import store from '~/badges/store';
import BadgeList from '~/badges/components/badge_list.vue';
Loading
Loading
@@ -22,6 +22,10 @@ describe('BadgeList component', () => {
kind: PROJECT_BADGE,
isLoading: false,
});
// Can be removed once GlLoadingIcon no longer throws a warning
jest.spyOn(global.console, 'warn').mockImplementation(() => jest.fn());
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
Loading
Loading
@@ -49,7 +53,7 @@ describe('BadgeList component', () => {
 
Vue.nextTick()
.then(() => {
expect(vm.$el).toContainText('This project has no badges');
expect(vm.$el.innerText).toMatch('This project has no badges');
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -82,7 +86,7 @@ describe('BadgeList component', () => {
 
Vue.nextTick()
.then(() => {
expect(vm.$el).toContainText('This group has no badges');
expect(vm.$el.innerText).toMatch('This group has no badges');
})
.then(done)
.catch(done.fail);
Loading
Loading
import $ from 'jquery';
import Vue from 'vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mountComponentWithStore } from 'helpers/vue_mount_component_helper';
import store from '~/badges/store';
import BadgeSettings from '~/badges/components/badge_settings.vue';
import { createDummyBadge } from '../dummy_badge';
Loading
Loading
@@ -19,6 +18,10 @@ describe('BadgeSettings component', () => {
data-target="#delete-badge-modal"
>Show modal</button>
`);
// Can be removed once GlLoadingIcon no longer throws a warning
jest.spyOn(global.console, 'warn').mockImplementation(() => jest.fn());
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
Loading
Loading
@@ -35,20 +38,16 @@ describe('BadgeSettings component', () => {
const modal = vm.$el.querySelector('#delete-badge-modal');
const button = document.getElementById('dummy-modal-button');
 
$(modal).on('shown.bs.modal', () => {
expect(modal).toContainText('Delete badge?');
const badgeElement = modal.querySelector('img.project-badge');
expect(badgeElement).not.toBe(null);
expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl);
done();
});
button.click();
 
Vue.nextTick()
.then(() => {
button.click();
expect(modal.innerText).toMatch('Delete badge?');
const badgeElement = modal.querySelector('img.project-badge');
expect(badgeElement).not.toBe(null);
expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl);
})
.then(done)
.catch(done.fail);
});
 
Loading
Loading
@@ -67,7 +66,7 @@ describe('BadgeSettings component', () => {
 
expect(badgeListElement).not.toBe(null);
expect(badgeListElement).toBeVisible();
expect(badgeListElement).toContainText('Your badges');
expect(badgeListElement.innerText).toMatch('Your badges');
});
 
describe('when editing', () => {
Loading
Loading
@@ -103,7 +102,7 @@ describe('BadgeSettings component', () => {
describe('methods', () => {
describe('onSubmitModal', () => {
it('triggers ', () => {
spyOn(vm, 'deleteBadge').and.callFake(() => Promise.resolve());
jest.spyOn(vm, 'deleteBadge').mockImplementation(() => Promise.resolve());
const modal = vm.$el.querySelector('#delete-badge-modal');
const deleteButton = modal.querySelector('.btn-danger');
 
Loading
Loading
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import mountComponent from 'helpers/vue_mount_component_helper';
import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants';
import Badge from '~/badges/components/badge.vue';
 
Loading
Loading
@@ -23,9 +23,11 @@ describe('Badge component', () => {
const createComponent = (props, el = null) => {
vm = mountComponent(Component, props, el);
const { badgeImage } = findElements();
return new Promise(resolve => badgeImage.addEventListener('load', resolve)).then(() =>
Vue.nextTick(),
);
return new Promise(resolve => {
badgeImage.addEventListener('load', resolve);
// Manually dispatch load event as it is not triggered
badgeImage.dispatchEvent(new Event('load'));
}).then(() => Vue.nextTick());
};
 
afterEach(() => {
Loading
Loading
@@ -111,7 +113,7 @@ describe('Badge component', () => {
expect(badgeImage).toBeVisible();
expect(loadingIcon).toBeHidden();
expect(reloadButton).toBeHidden();
expect(vm.$el.innerText).toBe('');
expect(vm.$el.querySelector('.btn-group')).toBeHidden();
});
 
it('shows a loading icon when loading', done => {
Loading
Loading
@@ -124,7 +126,7 @@ describe('Badge component', () => {
expect(badgeImage).toBeHidden();
expect(loadingIcon).toBeVisible();
expect(reloadButton).toBeHidden();
expect(vm.$el.innerText).toBe('');
expect(vm.$el.querySelector('.btn-group')).toBeHidden();
})
.then(done)
.catch(done.fail);
Loading
Loading
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'spec/test_constants';
import testAction from 'spec/helpers/vuex_action_helper';
import testAction from 'helpers/vuex_action_helper';
import axios from '~/lib/utils/axios_utils';
import actions, { transformBackendBadge } from '~/badges/store/actions';
import mutationTypes from '~/badges/store/mutation_types';
Loading
Loading
@@ -76,7 +76,7 @@ describe('Badges store actions', () => {
 
beforeEach(() => {
endpointMock = axiosMock.onPost(dummyEndpointUrl);
dispatch = jasmine.createSpy('dispatch');
dispatch = jest.fn();
badgeInAddForm = createDummyBadge();
state = {
...state,
Loading
Loading
@@ -96,8 +96,8 @@ describe('Badges store actions', () => {
}),
);
 
expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestNewBadge']]);
dispatch.mockClear();
return [200, dummyResponse];
});
 
Loading
Loading
@@ -105,7 +105,7 @@ describe('Badges store actions', () => {
actions
.addBadge({ state, dispatch })
.then(() => {
expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadge', dummyBadge]]);
expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -121,8 +121,8 @@ describe('Badges store actions', () => {
}),
);
 
expect(dispatch.calls.allArgs()).toEqual([['requestNewBadge']]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestNewBadge']]);
dispatch.mockClear();
return [500, ''];
});
 
Loading
Loading
@@ -130,7 +130,7 @@ describe('Badges store actions', () => {
.addBadge({ state, dispatch })
.then(() => done.fail('Expected Ajax call to fail!'))
.catch(() => {
expect(dispatch.calls.allArgs()).toEqual([['receiveNewBadgeError']]);
expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -182,20 +182,20 @@ describe('Badges store actions', () => {
 
beforeEach(() => {
endpointMock = axiosMock.onDelete(`${dummyEndpointUrl}/${badgeId}`);
dispatch = jasmine.createSpy('dispatch');
dispatch = jest.fn();
});
 
it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', done => {
endpointMock.replyOnce(() => {
expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]);
dispatch.mockClear();
return [200, ''];
});
 
actions
.deleteBadge({ state, dispatch }, { id: badgeId })
.then(() => {
expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadge', badgeId]]);
expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -203,8 +203,8 @@ describe('Badges store actions', () => {
 
it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', done => {
endpointMock.replyOnce(() => {
expect(dispatch.calls.allArgs()).toEqual([['requestDeleteBadge', badgeId]]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]);
dispatch.mockClear();
return [500, ''];
});
 
Loading
Loading
@@ -212,7 +212,7 @@ describe('Badges store actions', () => {
.deleteBadge({ state, dispatch }, { id: badgeId })
.then(() => done.fail('Expected Ajax call to fail!'))
.catch(() => {
expect(dispatch.calls.allArgs()).toEqual([['receiveDeleteBadgeError', badgeId]]);
expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -280,7 +280,7 @@ describe('Badges store actions', () => {
 
beforeEach(() => {
endpointMock = axiosMock.onGet(dummyEndpointUrl);
dispatch = jasmine.createSpy('dispatch');
dispatch = jest.fn();
});
 
it('dispatches requestLoadBadges and receiveLoadBadges for successful response', done => {
Loading
Loading
@@ -291,8 +291,8 @@ describe('Badges store actions', () => {
createDummyBadgeResponse(),
];
endpointMock.replyOnce(() => {
expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]);
dispatch.mockClear();
return [200, dummyReponse];
});
 
Loading
Loading
@@ -301,7 +301,7 @@ describe('Badges store actions', () => {
.then(() => {
const badges = dummyReponse.map(transformBackendBadge);
 
expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadges', badges]]);
expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -310,8 +310,8 @@ describe('Badges store actions', () => {
it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', done => {
const dummyData = 'this is just some data';
endpointMock.replyOnce(() => {
expect(dispatch.calls.allArgs()).toEqual([['requestLoadBadges', dummyData]]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]);
dispatch.mockClear();
return [500, ''];
});
 
Loading
Loading
@@ -319,7 +319,7 @@ describe('Badges store actions', () => {
.loadBadges({ state, dispatch }, dummyData)
.then(() => done.fail('Expected Ajax call to fail!'))
.catch(() => {
expect(dispatch.calls.allArgs()).toEqual([['receiveLoadBadgesError']]);
expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -382,11 +382,11 @@ describe('Badges store actions', () => {
`image_url=${encodeURIComponent(badgeInForm.imageUrl)}`,
].join('&');
endpointMock = axiosMock.onGet(`${dummyEndpointUrl}/render?${urlParameters}`);
dispatch = jasmine.createSpy('dispatch');
dispatch = jest.fn();
});
 
it('returns immediately if imageUrl is empty', done => {
spyOn(axios, 'get');
jest.spyOn(axios, 'get').mockImplementation(() => {});
badgeInForm.imageUrl = '';
 
actions
Loading
Loading
@@ -399,7 +399,7 @@ describe('Badges store actions', () => {
});
 
it('returns immediately if linkUrl is empty', done => {
spyOn(axios, 'get');
jest.spyOn(axios, 'get').mockImplementation(() => {});
badgeInForm.linkUrl = '';
 
actions
Loading
Loading
@@ -412,19 +412,23 @@ describe('Badges store actions', () => {
});
 
it('escapes user input', done => {
spyOn(axios, 'get').and.callFake(() => Promise.resolve({ data: createDummyBadgeResponse() }));
jest
.spyOn(axios, 'get')
.mockImplementation(() => Promise.resolve({ data: createDummyBadgeResponse() }));
badgeInForm.imageUrl = '&make-sandwich=true';
badgeInForm.linkUrl = '<script>I am dangerous!</script>';
 
actions
.renderBadge({ state, dispatch })
.then(() => {
expect(axios.get.calls.count()).toBe(1);
const url = axios.get.calls.argsFor(0)[0];
expect(url).toMatch(`^${dummyEndpointUrl}/render?`);
expect(url).toMatch('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&');
expect(url).toMatch('&image_url=%26make-sandwich%3Dtrue$');
expect(axios.get.mock.calls.length).toBe(1);
const url = axios.get.mock.calls[0][0];
expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`));
expect(url).toMatch(
new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'),
);
expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$'));
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -433,8 +437,8 @@ describe('Badges store actions', () => {
it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', done => {
const dummyReponse = createDummyBadgeResponse();
endpointMock.replyOnce(() => {
expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]);
dispatch.mockClear();
return [200, dummyReponse];
});
 
Loading
Loading
@@ -443,7 +447,7 @@ describe('Badges store actions', () => {
.then(() => {
const renderedBadge = transformBackendBadge(dummyReponse);
 
expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadge', renderedBadge]]);
expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -451,8 +455,8 @@ describe('Badges store actions', () => {
 
it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', done => {
endpointMock.replyOnce(() => {
expect(dispatch.calls.allArgs()).toEqual([['requestRenderedBadge']]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]);
dispatch.mockClear();
return [500, ''];
});
 
Loading
Loading
@@ -460,7 +464,7 @@ describe('Badges store actions', () => {
.renderBadge({ state, dispatch })
.then(() => done.fail('Expected Ajax call to fail!'))
.catch(() => {
expect(dispatch.calls.allArgs()).toEqual([['receiveRenderedBadgeError']]);
expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -519,7 +523,7 @@ describe('Badges store actions', () => {
badgeInEditForm,
};
endpointMock = axiosMock.onPut(`${dummyEndpointUrl}/${badgeInEditForm.id}`);
dispatch = jasmine.createSpy('dispatch');
dispatch = jest.fn();
});
 
it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', done => {
Loading
Loading
@@ -534,8 +538,8 @@ describe('Badges store actions', () => {
}),
);
 
expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestUpdatedBadge']]);
dispatch.mockClear();
return [200, dummyResponse];
});
 
Loading
Loading
@@ -543,7 +547,7 @@ describe('Badges store actions', () => {
actions
.saveBadge({ state, dispatch })
.then(() => {
expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadge', updatedBadge]]);
expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]);
})
.then(done)
.catch(done.fail);
Loading
Loading
@@ -559,8 +563,8 @@ describe('Badges store actions', () => {
}),
);
 
expect(dispatch.calls.allArgs()).toEqual([['requestUpdatedBadge']]);
dispatch.calls.reset();
expect(dispatch.mock.calls).toEqual([['requestUpdatedBadge']]);
dispatch.mockClear();
return [500, ''];
});
 
Loading
Loading
@@ -568,7 +572,7 @@ describe('Badges store actions', () => {
.saveBadge({ state, dispatch })
.then(() => done.fail('Expected Ajax call to fail!'))
.catch(() => {
expect(dispatch.calls.allArgs()).toEqual([['receiveUpdatedBadgeError']]);
expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]);
})
.then(done)
.catch(done.fail);
Loading
Loading
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import notesModule from '~/notes/stores/modules';
import DiscussionCounter from '~/notes/components/discussion_counter.vue';
import { noteableDataMock, discussionMock, notesDataMock, userDataMock } from '../mock_data';
import * as types from '~/notes/stores/mutation_types';
describe('DiscussionCounter component', () => {
let store;
let wrapper;
const localVue = createLocalVue();
localVue.use(Vuex);
beforeEach(() => {
window.mrTabs = {};
const { state, getters, mutations, actions } = notesModule();
store = new Vuex.Store({
state: {
...state,
userData: userDataMock,
},
getters,
mutations,
actions,
});
store.dispatch('setNoteableData', {
...noteableDataMock,
create_issue_to_resolve_discussions_path: '/test',
});
store.dispatch('setNotesData', notesDataMock);
});
afterEach(() => {
wrapper.vm.$destroy();
wrapper = null;
});
describe('has no discussions', () => {
it('does not render', () => {
wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(false);
});
});
describe('has no resolvable discussions', () => {
it('does not render', () => {
store.commit(types.SET_INITIAL_DISCUSSIONS, [{ ...discussionMock, resolvable: false }]);
store.dispatch('updateResolvableDiscussionsCounts');
wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(false);
});
});
describe('has resolvable discussions', () => {
const updateStore = (note = {}) => {
discussionMock.notes[0] = { ...discussionMock.notes[0], ...note };
store.commit(types.SET_INITIAL_DISCUSSIONS, [discussionMock]);
store.dispatch('updateResolvableDiscussionsCounts');
};
afterEach(() => {
delete discussionMock.notes[0].resolvable;
delete discussionMock.notes[0].resolved;
});
it('renders', () => {
updateStore();
wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find({ ref: 'discussionCounter' }).exists()).toBe(true);
});
it.each`
title | resolved | hasNextBtn | isActive | icon | groupLength
${'hasNextButton'} | ${false} | ${true} | ${false} | ${'check-circle'} | ${2}
${'allResolved'} | ${true} | ${false} | ${true} | ${'check-circle-filled'} | ${0}
`('renders correctly if $title', ({ resolved, hasNextBtn, isActive, icon, groupLength }) => {
updateStore({ resolvable: true, resolved });
wrapper = shallowMount(DiscussionCounter, { store, localVue });
expect(wrapper.find(`.has-next-btn`).exists()).toBe(hasNextBtn);
expect(wrapper.find(`.is-active`).exists()).toBe(isActive);
expect(wrapper.find({ name: icon }).exists()).toBe(true);
expect(wrapper.findAll('[role="group"').length).toBe(groupLength);
});
});
});
Loading
Loading
@@ -3,9 +3,12 @@ import JumpToNextDiscussionButton from '~/notes/components/discussion_jump_to_ne
 
describe('JumpToNextDiscussionButton', () => {
let wrapper;
const fromDiscussionId = 'abc123';
 
beforeEach(() => {
wrapper = shallowMount(JumpToNextDiscussionButton);
wrapper = shallowMount(JumpToNextDiscussionButton, {
propsData: { fromDiscussionId },
});
});
 
afterEach(() => {
Loading
Loading
@@ -15,4 +18,11 @@ describe('JumpToNextDiscussionButton', () => {
it('matches the snapshot', () => {
expect(wrapper.vm.$el).toMatchSnapshot();
});
it('calls jumpToNextRelativeDiscussion when clicked', () => {
const jumpToNextRelativeDiscussion = jest.fn();
wrapper.setMethods({ jumpToNextRelativeDiscussion });
wrapper.find({ ref: 'button' }).trigger('click');
expect(jumpToNextRelativeDiscussion).toHaveBeenCalledWith(fromDiscussionId);
});
});
/* global Mousetrap */
import 'mousetrap';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import DiscussionKeyboardNavigator from '~/notes/components/discussion_keyboard_navigator.vue';
import notesModule from '~/notes/stores/modules';
const localVue = createLocalVue();
localVue.use(Vuex);
const NEXT_ID = 'abc123';
const PREV_ID = 'def456';
const NEXT_DIFF_ID = 'abc123_diff';
const PREV_DIFF_ID = 'def456_diff';
 
describe('notes/components/discussion_keyboard_navigator', () => {
let storeOptions;
let wrapper;
let store;
const localVue = createLocalVue();
 
const createComponent = (options = {}) => {
store = new Vuex.Store(storeOptions);
let wrapper;
let jumpToNextDiscussion;
let jumpToPreviousDiscussion;
 
const createComponent = () => {
wrapper = shallowMount(DiscussionKeyboardNavigator, {
localVue,
store,
...options,
mixins: [
localVue.extend({
methods: {
jumpToNextDiscussion,
jumpToPreviousDiscussion,
},
}),
],
});
wrapper.vm.jumpToDiscussion = jest.fn();
};
 
beforeEach(() => {
const notes = notesModule();
notes.getters.nextUnresolvedDiscussionId = () => (currId, isDiff) =>
isDiff ? NEXT_DIFF_ID : NEXT_ID;
notes.getters.previousUnresolvedDiscussionId = () => (currId, isDiff) =>
isDiff ? PREV_DIFF_ID : PREV_ID;
notes.getters.getDiscussion = () => id => ({ id });
storeOptions = {
modules: {
notes,
},
};
jumpToNextDiscussion = jest.fn();
jumpToPreviousDiscussion = jest.fn();
});
 
afterEach(() => {
wrapper.destroy();
storeOptions = null;
store = null;
wrapper = null;
});
 
describe.each`
currentAction | expectedNextId | expectedPrevId
${'diffs'} | ${NEXT_DIFF_ID} | ${PREV_DIFF_ID}
${'show'} | ${NEXT_ID} | ${PREV_ID}
`('when isDiffView is $isDiffView', ({ currentAction, expectedNextId, expectedPrevId }) => {
describe('on mount', () => {
beforeEach(() => {
window.mrTabs = { currentAction };
createComponent();
});
afterEach(() => delete window.mrTabs);
 
it('calls jumpToNextDiscussion when pressing `n`', () => {
Mousetrap.trigger('n');
 
expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith(
expect.objectContaining({ id: expectedNextId }),
);
expect(wrapper.vm.currentDiscussionId).toEqual(expectedNextId);
expect(jumpToNextDiscussion).toHaveBeenCalled();
});
 
it('calls jumpToPreviousDiscussion when pressing `p`', () => {
Mousetrap.trigger('p');
 
expect(wrapper.vm.jumpToDiscussion).toHaveBeenCalledWith(
expect.objectContaining({ id: expectedPrevId }),
);
expect(wrapper.vm.currentDiscussionId).toEqual(expectedPrevId);
expect(jumpToPreviousDiscussion).toHaveBeenCalled();
});
});
 
Loading
Loading
@@ -99,13 +68,13 @@ describe('notes/components/discussion_keyboard_navigator', () => {
it('does not call jumpToNextDiscussion when pressing `n`', () => {
Mousetrap.trigger('n');
 
expect(wrapper.vm.jumpToDiscussion).not.toHaveBeenCalled();
expect(jumpToNextDiscussion).not.toHaveBeenCalled();
});
 
it('does not call jumpToNextDiscussion when pressing `p`', () => {
Mousetrap.trigger('p');
 
expect(wrapper.vm.jumpToDiscussion).not.toHaveBeenCalled();
expect(jumpToPreviousDiscussion).not.toHaveBeenCalled();
});
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import * as utils from '~/lib/utils/common_utils';
import discussionNavigation from '~/notes/mixins/discussion_navigation';
import eventHub from '~/notes/event_hub';
import notesModule from '~/notes/stores/modules';
import { setHTMLFixture } from 'helpers/fixtures';
const discussion = (id, index) => ({
id,
resolvable: index % 2 === 0,
active: true,
notes: [{}],
diff_discussion: true,
});
const createDiscussions = () => [...'abcde'].map(discussion);
const createComponent = () => ({
mixins: [discussionNavigation],
render() {
return this.$slots.default;
},
});
describe('Discussion navigation mixin', () => {
const localVue = createLocalVue();
localVue.use(Vuex);
let wrapper;
let store;
let expandDiscussion;
beforeEach(() => {
setHTMLFixture(
[...'abcde']
.map(
id =>
`<ul class="notes" data-discussion-id="${id}"></ul>
<div class="discussion" data-discussion-id="${id}"></div>`,
)
.join(''),
);
jest.spyOn(utils, 'scrollToElement');
expandDiscussion = jest.fn();
const { actions, ...notesRest } = notesModule();
store = new Vuex.Store({
modules: {
notes: {
...notesRest,
actions: { ...actions, expandDiscussion },
},
},
});
store.state.notes.discussions = createDiscussions();
wrapper = shallowMount(createComponent(), { store, localVue });
});
afterEach(() => {
wrapper.vm.$destroy();
jest.clearAllMocks();
});
const findDiscussion = (selector, id) =>
document.querySelector(`${selector}[data-discussion-id="${id}"]`);
describe('cycle through discussions', () => {
beforeEach(() => {
// eslint-disable-next-line new-cap
window.mrTabs = { eventHub: new localVue(), tabShown: jest.fn() };
});
describe.each`
fn | args | currentId | expected
${'jumpToNextDiscussion'} | ${[]} | ${null} | ${'a'}
${'jumpToNextDiscussion'} | ${[]} | ${'a'} | ${'c'}
${'jumpToNextDiscussion'} | ${[]} | ${'e'} | ${'a'}
${'jumpToPreviousDiscussion'} | ${[]} | ${null} | ${'e'}
${'jumpToPreviousDiscussion'} | ${[]} | ${'e'} | ${'c'}
${'jumpToPreviousDiscussion'} | ${[]} | ${'c'} | ${'a'}
${'jumpToNextRelativeDiscussion'} | ${[null]} | ${null} | ${'a'}
${'jumpToNextRelativeDiscussion'} | ${['a']} | ${null} | ${'c'}
${'jumpToNextRelativeDiscussion'} | ${['e']} | ${'c'} | ${'a'}
`('$fn (args = $args, currentId = $currentId)', ({ fn, args, currentId, expected }) => {
beforeEach(() => {
store.state.notes.currentDiscussionId = currentId;
});
describe('on `show` active tab', () => {
beforeEach(() => {
window.mrTabs.currentAction = 'show';
wrapper.vm[fn](...args);
});
it('sets current discussion', () => {
expect(store.state.notes.currentDiscussionId).toEqual(expected);
});
it('expands discussion', () => {
expect(expandDiscussion).toHaveBeenCalled();
});
it('scrolls to element', () => {
expect(utils.scrollToElement).toHaveBeenCalledWith(
findDiscussion('div.discussion', expected),
);
});
});
describe('on `diffs` active tab', () => {
beforeEach(() => {
window.mrTabs.currentAction = 'diffs';
wrapper.vm[fn](...args);
});
it('sets current discussion', () => {
expect(store.state.notes.currentDiscussionId).toEqual(expected);
});
it('expands discussion', () => {
expect(expandDiscussion).toHaveBeenCalled();
});
it('scrolls when scrollToDiscussion is emitted', () => {
expect(utils.scrollToElement).not.toHaveBeenCalled();
eventHub.$emit('scrollToDiscussion');
expect(utils.scrollToElement).toHaveBeenCalledWith(findDiscussion('ul.notes', expected));
});
});
describe('on `other` active tab', () => {
beforeEach(() => {
window.mrTabs.currentAction = 'other';
wrapper.vm[fn](...args);
});
it('sets current discussion', () => {
expect(store.state.notes.currentDiscussionId).toEqual(expected);
});
it('does not expand discussion yet', () => {
expect(expandDiscussion).not.toHaveBeenCalled();
});
it('shows mrTabs', () => {
expect(window.mrTabs.tabShown).toHaveBeenCalledWith('show');
});
describe('when tab is changed', () => {
beforeEach(() => {
window.mrTabs.eventHub.$emit('MergeRequestTabChange');
jest.runAllTimers();
});
it('expands discussion', () => {
expect(expandDiscussion).toHaveBeenCalledWith(
expect.anything(),
{
discussionId: expected,
},
undefined,
);
});
it('scrolls to discussion', () => {
expect(utils.scrollToElement).toHaveBeenCalledWith(
findDiscussion('div.discussion', expected),
);
});
});
});
});
});
});
import Vue from 'vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import createStore from '~/notes/stores';
import DiscussionCounter from '~/notes/components/discussion_counter.vue';
import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data';
describe('DiscussionCounter component', () => {
let store;
let vm;
const notes = { currentDiscussionId: null };
beforeEach(() => {
window.mrTabs = {};
const Component = Vue.extend(DiscussionCounter);
store = createStore();
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);
vm = createComponentWithStore(Component, store);
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('jumpToNextDiscussion', () => {
it('expands unresolved discussion', () => {
window.mrTabs.currentAction = 'show';
spyOn(vm, 'expandDiscussion').and.stub();
const discussions = [
{
...discussionMock,
id: discussionMock.id,
notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }],
resolved: true,
},
{
...discussionMock,
id: discussionMock.id + 1,
notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }],
resolved: false,
},
];
const firstDiscussionId = discussionMock.id + 1;
store.replaceState({
...store.state,
discussions,
notes,
});
vm.jumpToNextDiscussion();
expect(vm.expandDiscussion).toHaveBeenCalledWith({ discussionId: firstDiscussionId });
});
it('jumps to next unresolved discussion from diff tab if all diff discussions are resolved', () => {
window.mrTabs.currentAction = 'diff';
spyOn(vm, 'switchToDiscussionsTabAndJumpTo').and.stub();
const unresolvedId = discussionMock.id + 1;
const discussions = [
{
...discussionMock,
id: discussionMock.id,
diff_discussion: true,
notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: true }],
resolved: true,
},
{
...discussionMock,
id: unresolvedId,
notes: [{ ...discussionMock.notes[0], resolvable: true, resolved: false }],
resolved: false,
},
];
store.replaceState({
...store.state,
discussions,
notes,
});
vm.jumpToNextDiscussion();
expect(vm.switchToDiscussionsTabAndJumpTo).toHaveBeenCalledWith(unresolvedId);
});
});
});
});
Loading
Loading
@@ -271,6 +271,7 @@ MergeRequest::Metrics:
- diff_size
- modified_paths_size
- commits_count
- first_approved_at
Ci::Pipeline:
- id
- project_id
Loading
Loading
Loading
Loading
@@ -37,5 +37,40 @@ describe Gitlab::JobWaiter do
 
expect(result).to contain_exactly('a')
end
context 'when a label is provided' do
let(:waiter) { described_class.new(2, worker_label: 'Foo') }
let(:started_total) { double(:started_total) }
let(:timeouts_total) { double(:timeouts_total) }
before do
allow(Gitlab::Metrics).to receive(:counter)
.with(described_class::STARTED_METRIC, anything)
.and_return(started_total)
allow(Gitlab::Metrics).to receive(:counter)
.with(described_class::TIMEOUTS_METRIC, anything)
.and_return(timeouts_total)
end
it 'increments just job_waiter_started_total when all jobs complete' do
expect(started_total).to receive(:increment).with(worker: 'Foo')
expect(timeouts_total).not_to receive(:increment)
described_class.notify(waiter.key, 'a')
described_class.notify(waiter.key, 'b')
result = nil
expect { Timeout.timeout(1) { result = waiter.wait(2) } }.not_to raise_error
end
it 'increments job_waiter_started_total and job_waiter_timeouts_total when it times out' do
expect(started_total).to receive(:increment).with(worker: 'Foo')
expect(timeouts_total).to receive(:increment).with(worker: 'Foo')
result = nil
expect { Timeout.timeout(2) { result = waiter.wait(1) } }.not_to raise_error
end
end
end
end
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment