Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-06-09 09:08:20 +00:00
parent 8288587742
commit d9f331328a
30 changed files with 501 additions and 391 deletions

View File

@ -360,5 +360,6 @@ rspec foss-impact:
expire_in: 7d
paths:
- tmp/matching_foss_tests.txt
- tmp/capybara/
# EE: Merge Request pipelines
##################################################

View File

@ -25,9 +25,10 @@ function importMermaidModule() {
return import(/* webpackChunkName: 'mermaid' */ 'mermaid')
.then(mermaid => {
let theme = 'neutral';
const ideDarkThemes = ['dark', 'solarized-dark'];
if (
window.gon?.user_color_scheme === 'dark' &&
ideDarkThemes.includes(window.gon?.user_color_scheme) &&
// if on the Web IDE page
document.querySelector('.ide')
) {

View File

@ -282,12 +282,16 @@ export default {
},
methods: {
installClicked() {
if (this.disabled || this.installButtonDisabled) return;
eventHub.$emit('installApplication', {
id: this.id,
params: this.installApplicationRequestParams,
});
},
updateConfirmed() {
if (this.isUpdating) return;
eventHub.$emit('updateApplication', {
id: this.id,
params: this.installApplicationRequestParams,

View File

@ -3,7 +3,7 @@
export default {
computed: {
canSeeDescriptionVersion() {},
canDeleteDescriptionVersion() {},
displayDeleteButton() {},
shouldShowDescriptionVersion() {},
descriptionVersionToggleIcon() {},
},

View File

@ -630,6 +630,10 @@ export const softDeleteDescriptionVersion = (
.catch(error => {
dispatch('receiveDeleteDescriptionVersionError', error);
Flash(__('Something went wrong while deleting description changes. Please try again.'));
// Throw an error here because a component like SystemNote -
// needs to know if the request failed to reset its internal state.
throw new Error();
});
};

View File

@ -2,12 +2,14 @@
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import PublishToolbar from './publish_toolbar.vue';
import EditHeader from './edit_header.vue';
import UnsavedChangesConfirmDialog from './unsaved_changes_confirm_dialog.vue';
export default {
components: {
RichContentEditor,
PublishToolbar,
EditHeader,
UnsavedChangesConfirmDialog,
},
props: {
title: {
@ -50,6 +52,7 @@ export default {
<div class="d-flex flex-grow-1 flex-column h-100">
<edit-header class="py-2" :title="title" />
<rich-content-editor v-model="editableContent" class="mb-9 h-100" />
<unsaved-changes-confirm-dialog :modified="modified" />
<publish-toolbar
class="gl-fixed gl-left-0 gl-bottom-0 gl-w-full"
:return-url="returnUrl"

View File

@ -0,0 +1,27 @@
<script>
export default {
props: {
modified: {
type: Boolean,
required: false,
default: false,
},
},
created() {
window.addEventListener('beforeunload', this.requestConfirmation);
},
destroyed() {
window.removeEventListener('beforeunload', this.requestConfirmation);
},
methods: {
requestConfirmation(e) {
if (this.modified) {
e.preventDefault();
// eslint-disable-next-line no-param-reassign
e.returnValue = '';
}
},
},
render: () => null,
};
</script>

View File

@ -132,7 +132,7 @@ export default {
</pre>
<pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre>
<gl-deprecated-button
v-if="canDeleteDescriptionVersion"
v-if="displayDeleteButton"
ref="deleteDescriptionVersionButton"
v-gl-tooltip
:title="__('Remove description history')"

View File

@ -5,6 +5,7 @@
@import './ide_theme_overrides';
@import './ide_themes/dark';
@import './ide_themes/solarized-dark';
$search-list-icon-width: 18px;
$ide-activity-bar-width: 60px;
@ -89,7 +90,7 @@ $ide-commit-header-height: 48px;
&.active {
background-color: var(--ide-highlight-background, $white);
border-bottom-color: var(--ide-border-color, $white);
border-bottom-color: transparent;
}
&:not(.disabled) {

View File

@ -0,0 +1,50 @@
// -------
// Please see `app/assets/stylesheets/page_bundles/ide_themes/README.md` for a guide on contributing new themes
// -------
.ide.theme-solarized-dark {
--ide-border-color: #002c38;
--ide-border-color-alt: var(--ide-background);
--ide-highlight-accent: #fff;
--ide-text-color: #ddd;
--ide-text-color-secondary: #ddd;
--ide-background: #004152;
--ide-background-hover: #003b4d;
--ide-highlight-background: #003240;
--ide-link-color: #73b9ff;
--ide-footer-background: var(--ide-highlight-background);
--ide-input-border: #d8d8d8;
--ide-input-background: transparent;
--ide-input-color: #fff;
--ide-btn-default-background: transparent;
--ide-btn-default-border: var(--ide-input-border);
--ide-btn-default-hover-border: #d8d8d8;
--ide-btn-primary-background: #1068bf;
--ide-btn-primary-border: #428fdc;
--ide-btn-primary-hover-border: #63a6e9;
--ide-btn-success-background: #217645;
--ide-btn-success-border: #108548;
--ide-btn-success-hover-border: #2da160;
--ide-btn-disabled-border: rgba(223, 223, 223, 0.24);
--ide-btn-disabled-color: rgba(145, 145, 145, 0.48);
--ide-btn-hover-border-width: 2px;
--ide-dropdown-background: #004c61;
--ide-dropdown-hover-background: #00617a;
--ide-dropdown-btn-hover-border: #e9ecef;
--ide-dropdown-btn-hover-background: var(--ide-background-hover);
--ide-file-row-btn-hover-background: #005a73;
--ide-diff-insert: rgba(155, 185, 85, 0.2);
--ide-diff-remove: rgba(255, 0, 0, 0.2);
--ide-animation-gradient-1: var(--ide-file-row-btn-hover-background);
--ide-animation-gradient-2: var(--ide-dropdown-hover-background);
}

View File

@ -66,7 +66,7 @@ module Timebox
groups = groups.compact if groups.is_a? Array
groups = [] if groups.nil?
if Feature.enabled?(:optimized_timebox_queries)
if Feature.enabled?(:optimized_timebox_queries, default_enabled: true)
from_union([where(project_id: projects), where(group_id: groups)], remove_duplicates: false)
else
where(project_id: projects).or(where(group_id: groups))

View File

@ -0,0 +1,5 @@
---
title: Display confirmation modal when user exits SSE and there are unsaved changes
merge_request: 33103
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Add solarized dark for Web IDE
merge_request: 33148
author:
type: added

View File

@ -17,10 +17,6 @@ module Gitlab
class Application < Rails::Application
require_dependency Rails.root.join('lib/gitlab')
require_dependency Rails.root.join('lib/gitlab/utils')
require_dependency Rails.root.join('lib/gitlab/redis/wrapper')
require_dependency Rails.root.join('lib/gitlab/redis/cache')
require_dependency Rails.root.join('lib/gitlab/redis/queues')
require_dependency Rails.root.join('lib/gitlab/redis/shared_state')
require_dependency Rails.root.join('lib/gitlab/current_settings')
require_dependency Rails.root.join('lib/gitlab/middleware/read_only')
require_dependency Rails.root.join('lib/gitlab/middleware/basic_health_check')
@ -262,17 +258,6 @@ module Gitlab
end
end
# Use caching across all environments
# Full list of options:
# https://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html#method-c-new
caching_config_hash = {}
caching_config_hash[:redis] = Gitlab::Redis::Cache.pool
caching_config_hash[:compress] = Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_REDIS_CACHE_COMPRESSION', '1'))
caching_config_hash[:namespace] = Gitlab::Redis::Cache::CACHE_NAMESPACE
caching_config_hash[:expires_in] = 2.weeks # Cache should not grow forever
config.cache_store = :redis_cache_store, caching_config_hash
config.active_job.queue_adapter = :sidekiq
# This is needed for gitlab-shell

View File

@ -1070,6 +1070,9 @@ production: &base
## ActionCable settings
action_cable:
# Enables handling of ActionCable requests on the Puma web workers
# When this is disabled, a standalone ActionCable server must be started
in_app: true
# Number of threads used to process ActionCable connection callbacks and channel actions
# worker_pool_size: 4

View File

@ -726,6 +726,7 @@ Settings.webpack.dev_server['port'] ||= 3808
# ActionCable settings
#
Settings['action_cable'] ||= Settingslogic.new({})
Settings.action_cable['in_app'] ||= false
Settings.action_cable['worker_pool_size'] ||= 4
#

View File

@ -1,3 +1,14 @@
# Use caching across all environments
# Full list of options:
# https://api.rubyonrails.org/classes/ActiveSupport/Cache/RedisCacheStore.html#method-c-new
caching_config_hash = {}
caching_config_hash[:redis] = Gitlab::Redis::Cache.pool
caching_config_hash[:compress] = Gitlab::Utils.to_boolean(ENV.fetch('ENABLE_REDIS_CACHE_COMPRESSION', '1'))
caching_config_hash[:namespace] = Gitlab::Redis::Cache::CACHE_NAMESPACE
caching_config_hash[:expires_in] = 2.weeks # Cache should not grow forever
Gitlab::Application.config.cache_store = :redis_cache_store, caching_config_hash
# Make sure we initialize a Redis connection pool before multi-threaded
# execution starts by
# 1. Sidekiq

View File

@ -3,9 +3,9 @@
require 'action_cable/subscription_adapter/redis'
Rails.application.configure do
# We only mount the ActionCable engine in tests where we run it in-app
# For other environments, we run it on a standalone Puma server
config.action_cable.mount_path = Rails.env.test? ? '/-/cable' : nil
# Mount the ActionCable engine when in-app mode is enabled
config.action_cable.mount_path = Gitlab.config.action_cable.in_app ? '/-/cable' : nil
config.action_cable.url = Gitlab::Utils.append_path(Gitlab.config.gitlab.relative_url_root, '/-/cable')
config.action_cable.worker_pool_size = Gitlab.config.action_cable.worker_pool_size
end

View File

@ -141,6 +141,9 @@ that can also be promoted in case of disaster.
## Configure GitLab to scale
NOTE: **Note:**
From GitLab 13.0, using NFS for Git repositories is deprecated. In GitLab 14.0, support for NFS for Git repositories is scheduled to be removed. Upgrade to [Gitaly Cluster](../gitaly/praefect.md) as soon as possible.
The following components are the ones you need to configure in order to scale
GitLab. They are listed in the order you'll typically configure them if they are
required by your [reference architecture](#reference-architectures) of choice.
@ -163,7 +166,7 @@ column.
| Repmgr | PostgreSQL cluster management and failover | [PostgreSQL and Repmgr configuration](../high_availability/database.md) | Yes |
| [Redis](../../development/architecture.md#redis) ([3](#footnotes)) | Key/value store for fast data lookup and caching | [Redis configuration](../high_availability/redis.md) | Yes |
| Redis Sentinel | Redis | [Redis Sentinel configuration](../high_availability/redis.md) | Yes |
| [Gitaly](../../development/architecture.md#gitaly) ([2](#footnotes)) ([7](#footnotes)) ([9](#footnotes)) | Provides access to Git repositories | [Gitaly configuration](../gitaly/index.md#running-gitaly-on-its-own-server) | Yes |
| [Gitaly](../../development/architecture.md#gitaly) ([2](#footnotes)) ([7](#footnotes)) | Provides access to Git repositories | [Gitaly configuration](../gitaly/index.md#running-gitaly-on-its-own-server) | Yes |
| [Sidekiq](../../development/architecture.md#sidekiq) | Asynchronous/background jobs | [Sidekiq configuration](../high_availability/sidekiq.md) | Yes |
| [GitLab application services](../../development/architecture.md#unicorn)([1](#footnotes)) | Puma/Unicorn, Workhorse, GitLab Shell - serves front-end requests (UI, API, Git over HTTP/SSH) | [GitLab app scaling configuration](../high_availability/gitlab.md) | Yes |
| [Prometheus](../../development/architecture.md#prometheus) and [Grafana](../../development/architecture.md#grafana) | GitLab environment monitoring | [Monitoring node for scaling](../high_availability/monitoring_node.md) | Yes |
@ -177,9 +180,7 @@ column.
on workload.
1. Gitaly node requirements are dependent on customer data, specifically the number of
projects and their sizes. We recommend two nodes as an absolute minimum,
and at least four nodes should be used when supporting 50,000 or more users.
We also recommend that each Gitaly node should store no more than 5TB of data
projects and their sizes. We recommend that each Gitaly node should store no more than 5TB of data
and have the number of [`gitaly-ruby` workers](../gitaly/index.md#gitaly-ruby)
set to 20% of available CPUs. Additional nodes should be considered in conjunction
with a review of expected data size and spread based on the recommendations above.
@ -216,7 +217,3 @@ column.
or higher, are required for your CPU or Node counts accordingly. For more information, a
[Sysbench](https://github.com/akopytov/sysbench) benchmark of the CPU can be found
[here](https://gitlab.com/gitlab-org/quality/performance/-/wikis/Reference-Architectures/GCP-CPU-Benchmarks).
1. From GitLab 13.0, using NFS for Git repositories is deprecated. In GitLab
14.0, support for NFS for Git repositories is scheduled to be removed.
Upgrade to [Gitaly Cluster](../gitaly/praefect.md) as soon as possible.

View File

@ -37,7 +37,7 @@ module Gitlab
end
def puma?
!!defined?(::Puma) && !defined?(ACTION_CABLE_SERVER)
!!defined?(::Puma)
end
# For unicorn, we need to check for actual server instances to avoid false positives.
@ -70,11 +70,11 @@ module Gitlab
end
def web_server?
puma? || unicorn? || action_cable?
puma? || unicorn?
end
def action_cable?
!!defined?(ACTION_CABLE_SERVER)
web_server? && (!!defined?(ACTION_CABLE_SERVER) || Gitlab.config.action_cable.in_app)
end
def multi_threaded?
@ -82,19 +82,21 @@ module Gitlab
end
def max_threads
main_thread = 1
threads = 1 # main thread
if action_cable?
Gitlab::Application.config.action_cable.worker_pool_size
elsif puma?
Puma.cli_config.options[:max_threads]
if puma?
threads += Puma.cli_config.options[:max_threads]
elsif sidekiq?
# An extra thread for the poller in Sidekiq Cron:
# https://github.com/ondrejbartas/sidekiq-cron#under-the-hood
Sidekiq.options[:concurrency] + 1
else
0
end + main_thread
threads += Sidekiq.options[:concurrency] + 1
end
if action_cable?
threads += Gitlab.config.action_cable.worker_pool_size
end
threads
end
end
end

View File

@ -165,7 +165,7 @@ module QA
raise ResourceUpdateFailedError, "Could not change repository storage to #{new_storage}. Request returned (#{response.code}): `#{response}`."
end
wait_until(sleep_interval: 1) { Runtime::API::RepositoryStorageMoves.has_status?(self, 'finished') }
wait_until(sleep_interval: 1) { Runtime::API::RepositoryStorageMoves.has_status?(self, 'finished', new_storage) }
rescue Support::Repeater::RepeaterConditionExceededError
raise Runtime::API::RepositoryStorageMoves::RepositoryStorageMovesError, 'Timed out while waiting for the repository storage move to finish'
end

View File

@ -9,11 +9,11 @@ module QA
RepositoryStorageMovesError = Class.new(RuntimeError)
def has_status?(project, status)
def has_status?(project, status, destination_storage = Env.additional_repository_storage)
all.any? do |move|
move[:project][:path_with_namespace] == project.path_with_namespace &&
move[:state] == status &&
move[:destination_storage_name] == Env.additional_repository_storage
move[:destination_storage_name] == destination_storage
end
end

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
module QA
context 'Create' do
describe 'Changing Gitaly repository storage', :orchestrated, :requires_admin do
shared_examples 'repository storage move' do
it 'confirms a `finished` status after moving project repository storage' do
expect(project).to have_file('README.md')
project.change_repository_storage(destination_storage)
expect(Runtime::API::RepositoryStorageMoves).to have_status(project, 'finished', destination_storage)
Resource::Repository::ProjectPush.fabricate! do |push|
push.project = project
push.file_name = 'new_file'
push.file_content = '# This is a new file'
push.commit_message = 'Add new file'
push.new_branch = false
end
expect(project).to have_file('README.md')
expect(project).to have_file('new_file')
end
end
context 'when moving from one Gitaly storage to another', :repository_storage do
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'repo-storage-move-status'
project.initialize_with_readme = true
end
end
let(:destination_storage) { QA::Runtime::Env.additional_repository_storage }
it_behaves_like 'repository storage move'
end
context 'when moving from Gitaly to Gitaly Cluster', :requires_praefect do
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'repo-storage-move'
project.initialize_with_readme = true
project.repository_storage = 'gitaly'
end
end
let(:destination_storage) { QA::Runtime::Env.praefect_repository_storage }
it_behaves_like 'repository storage move'
end
end
end
end

View File

@ -1,33 +0,0 @@
# frozen_string_literal: true
module QA
context 'Create' do
describe 'Gitaly repository storage', :orchestrated, :repository_storage, :requires_admin do
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'repo-storage-status'
project.initialize_with_readme = true
end
end
it 'confirms a `finished` status after moving project repository storage' do
expect(project).to have_file('README.md')
project.change_repository_storage(QA::Runtime::Env.additional_repository_storage)
expect(Runtime::API::RepositoryStorageMoves).to have_status(project, 'finished')
Resource::Repository::ProjectPush.fabricate! do |push|
push.project = project
push.file_name = 'new_file'
push.file_content = '# This is a new file'
push.commit_message = 'Add new file'
push.new_branch = false
end
expect(project).to have_file('README.md')
expect(project).to have_file('new_file')
end
end
end
end

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
module QA
context 'Create' do
context 'Create', quarantine: { issue: 'https://gitlab.com/gitlab-org/gitlab/-/issues/220189', type: :investigating } do
describe 'Merge request creation from fork' do
it 'user forks a project, submits a merge request and maintainer merges it' do
Flow::Login.sign_in

View File

@ -1,243 +1,192 @@
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import mountComponent from 'helpers/vue_mount_component_helper';
import eventHub from '~/clusters/event_hub';
import { APPLICATION_STATUS, ELASTIC_STACK } from '~/clusters/constants';
import applicationRow from '~/clusters/components/application_row.vue';
import ApplicationRow from '~/clusters/components/application_row.vue';
import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue';
import UpdateApplicationConfirmationModal from '~/clusters/components/update_application_confirmation_modal.vue';
import { DEFAULT_APPLICATION_STATE } from '../services/mock_data';
describe('Application Row', () => {
let vm;
let ApplicationRow;
beforeEach(() => {
ApplicationRow = Vue.extend(applicationRow);
});
let wrapper;
afterEach(() => {
vm.$destroy();
wrapper.destroy();
});
const mountComponent = data => {
wrapper = shallowMount(ApplicationRow, {
propsData: {
...DEFAULT_APPLICATION_STATE,
...data,
},
});
};
describe('Title', () => {
it('shows title', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
titleLink: null,
});
const title = vm.$el.querySelector('.js-cluster-application-title');
mountComponent({ titleLink: null });
expect(title.tagName).toEqual('SPAN');
expect(title.textContent.trim()).toEqual(DEFAULT_APPLICATION_STATE.title);
const title = wrapper.find('.js-cluster-application-title');
expect(title.element).toBeInstanceOf(HTMLSpanElement);
expect(title.text()).toEqual(DEFAULT_APPLICATION_STATE.title);
});
it('shows title link', () => {
expect(DEFAULT_APPLICATION_STATE.titleLink).toBeDefined();
mountComponent();
const title = wrapper.find('.js-cluster-application-title');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
});
const title = vm.$el.querySelector('.js-cluster-application-title');
expect(title.tagName).toEqual('A');
expect(title.textContent.trim()).toEqual(DEFAULT_APPLICATION_STATE.title);
expect(title.element).toBeInstanceOf(HTMLAnchorElement);
expect(title.text()).toEqual(DEFAULT_APPLICATION_STATE.title);
});
});
describe('Install button', () => {
it('has indeterminate state on page load', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: null,
});
const button = () => wrapper.find('.js-cluster-application-install-button');
const checkButtonState = (label, loading, disabled) => {
expect(button().props('label')).toEqual(label);
expect(button().props('loading')).toEqual(loading);
expect(button().props('disabled')).toEqual(disabled);
};
expect(vm.installButtonLabel).toBeUndefined();
it('has indeterminate state on page load', () => {
mountComponent({ status: null });
expect(button().props('label')).toBeUndefined();
});
it('has install button', () => {
const installationBtn = vm.$el.querySelector('.js-cluster-application-install-button');
mountComponent();
expect(installationBtn).not.toBe(null);
expect(button().exists()).toBe(true);
});
it('has disabled "Install" when APPLICATION_STATUS.NOT_INSTALLABLE', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.NOT_INSTALLABLE,
});
mountComponent({ status: APPLICATION_STATUS.NOT_INSTALLABLE });
expect(vm.installButtonLabel).toEqual('Install');
expect(vm.installButtonLoading).toEqual(false);
expect(vm.installButtonDisabled).toEqual(true);
checkButtonState('Install', false, true);
});
it('has enabled "Install" when APPLICATION_STATUS.INSTALLABLE', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
});
mountComponent({ status: APPLICATION_STATUS.INSTALLABLE });
expect(vm.installButtonLabel).toEqual('Install');
expect(vm.installButtonLoading).toEqual(false);
expect(vm.installButtonDisabled).toEqual(false);
checkButtonState('Install', false, false);
});
it('has loading "Installing" when APPLICATION_STATUS.INSTALLING', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLING,
});
mountComponent({ status: APPLICATION_STATUS.INSTALLING });
expect(vm.installButtonLabel).toEqual('Installing');
expect(vm.installButtonLoading).toEqual(true);
expect(vm.installButtonDisabled).toEqual(true);
checkButtonState('Installing', true, true);
});
it('has disabled "Installed" when application is installed and not uninstallable', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.INSTALLED,
installed: true,
uninstallable: false,
});
expect(vm.installButtonLabel).toEqual('Installed');
expect(vm.installButtonLoading).toEqual(false);
expect(vm.installButtonDisabled).toEqual(true);
checkButtonState('Installed', false, true);
});
it('hides when application is installed and uninstallable', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.INSTALLED,
installed: true,
uninstallable: true,
});
const installBtn = vm.$el.querySelector('.js-cluster-application-install-button');
expect(installBtn).toBe(null);
expect(button().exists()).toBe(false);
});
it('has enabled "Install" when install fails', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.INSTALLABLE,
installFailed: true,
});
expect(vm.installButtonLabel).toEqual('Install');
expect(vm.installButtonLoading).toEqual(false);
expect(vm.installButtonDisabled).toEqual(false);
checkButtonState('Install', false, false);
});
it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
});
mountComponent({ status: APPLICATION_STATUS.INSTALLABLE });
expect(vm.installButtonLabel).toEqual('Install');
expect(vm.installButtonLoading).toEqual(false);
expect(vm.installButtonDisabled).toEqual(false);
checkButtonState('Install', false, false);
});
it('clicking install button emits event', () => {
jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLABLE,
});
const installButton = vm.$el.querySelector('.js-cluster-application-install-button');
const spy = jest.spyOn(eventHub, '$emit');
mountComponent({ status: APPLICATION_STATUS.INSTALLABLE });
installButton.click();
button().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', {
expect(spy).toHaveBeenCalledWith('installApplication', {
id: DEFAULT_APPLICATION_STATE.id,
params: {},
});
});
it('clicking install button when installApplicationRequestParams are provided emits event', () => {
jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
const spy = jest.spyOn(eventHub, '$emit');
mountComponent({
status: APPLICATION_STATUS.INSTALLABLE,
installApplicationRequestParams: { hostname: 'jupyter' },
});
const installButton = vm.$el.querySelector('.js-cluster-application-install-button');
installButton.click();
button().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', {
expect(spy).toHaveBeenCalledWith('installApplication', {
id: DEFAULT_APPLICATION_STATE.id,
params: { hostname: 'jupyter' },
});
});
it('clicking disabled install button emits nothing', () => {
jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.INSTALLING,
});
const installButton = vm.$el.querySelector('.js-cluster-application-install-button');
const spy = jest.spyOn(eventHub, '$emit');
mountComponent({ status: APPLICATION_STATUS.INSTALLING });
expect(vm.installButtonDisabled).toEqual(true);
expect(button().props('disabled')).toEqual(true);
installButton.click();
button().vm.$emit('click');
expect(eventHub.$emit).not.toHaveBeenCalled();
expect(spy).not.toHaveBeenCalled();
});
});
describe('Uninstall button', () => {
it('displays button when app is installed and uninstallable', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
installed: true,
uninstallable: true,
status: APPLICATION_STATUS.NOT_INSTALLABLE,
});
const uninstallButton = vm.$el.querySelector('.js-cluster-application-uninstall-button');
const uninstallButton = wrapper.find('.js-cluster-application-uninstall-button');
expect(uninstallButton).toBeTruthy();
expect(uninstallButton.exists()).toBe(true);
});
it('displays a success toast message if application uninstall was successful', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
it('displays a success toast message if application uninstall was successful', async () => {
mountComponent({
title: 'GitLab Runner',
uninstallSuccessful: false,
});
vm.$toast = { show: jest.fn() };
vm.uninstallSuccessful = true;
wrapper.vm.$toast = { show: jest.fn() };
wrapper.setProps({ uninstallSuccessful: true });
return vm.$nextTick(() => {
expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner uninstalled successfully.');
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
'GitLab Runner uninstalled successfully.',
);
});
});
describe('when confirmation modal triggers confirm event', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(ApplicationRow, {
propsData: {
...DEFAULT_APPLICATION_STATE,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('triggers uninstallApplication event', () => {
jest.spyOn(eventHub, '$emit');
mountComponent();
wrapper.find(UninstallApplicationConfirmationModal).vm.$emit('confirm');
expect(eventHub.$emit).toHaveBeenCalledWith('uninstallApplication', {
@ -247,126 +196,91 @@ describe('Application Row', () => {
});
describe('Update button', () => {
it('has indeterminate state on page load', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: null,
});
const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button');
const button = () => wrapper.find('.js-cluster-application-update-button');
expect(updateBtn).toBe(null);
it('has indeterminate state on page load', () => {
mountComponent();
expect(button().exists()).toBe(false);
});
it('has enabled "Update" when "updateAvailable" is true', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
updateAvailable: true,
});
const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button');
mountComponent({ updateAvailable: true });
expect(updateBtn).not.toBe(null);
expect(updateBtn.innerHTML).toContain('Update');
expect(button().exists()).toBe(true);
expect(button().props('label')).toContain('Update');
});
it('has enabled "Retry update" when update process fails', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.INSTALLED,
updateFailed: true,
});
const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button');
expect(updateBtn).not.toBe(null);
expect(updateBtn.innerHTML).toContain('Retry update');
expect(button().exists()).toBe(true);
expect(button().props('label')).toContain('Retry update');
});
it('has disabled "Updating" when APPLICATION_STATUS.UPDATING', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATING,
});
const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button');
mountComponent({ status: APPLICATION_STATUS.UPDATING });
expect(updateBtn).not.toBe(null);
expect(vm.isUpdating).toBe(true);
expect(updateBtn.innerHTML).toContain('Updating');
expect(button().exists()).toBe(true);
expect(button().props('label')).toContain('Updating');
});
it('clicking update button emits event', () => {
jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
const spy = jest.spyOn(eventHub, '$emit');
mountComponent({
status: APPLICATION_STATUS.INSTALLED,
updateAvailable: true,
});
const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button');
updateBtn.click();
button().vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('updateApplication', {
expect(spy).toHaveBeenCalledWith('updateApplication', {
id: DEFAULT_APPLICATION_STATE.id,
params: {},
});
});
it('clicking disabled update button emits nothing', () => {
jest.spyOn(eventHub, '$emit');
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
status: APPLICATION_STATUS.UPDATING,
});
const updateBtn = vm.$el.querySelector('.js-cluster-application-update-button');
const spy = jest.spyOn(eventHub, '$emit');
mountComponent({ status: APPLICATION_STATUS.UPDATING });
updateBtn.click();
button().vm.$emit('click');
expect(eventHub.$emit).not.toHaveBeenCalled();
expect(spy).not.toHaveBeenCalled();
});
it('displays an error message if application update failed', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
title: 'GitLab Runner',
status: APPLICATION_STATUS.INSTALLED,
updateFailed: true,
});
const failureMessage = vm.$el.querySelector('.js-cluster-application-update-details');
const failureMessage = wrapper.find('.js-cluster-application-update-details');
expect(failureMessage).not.toBe(null);
expect(failureMessage.innerHTML).toContain(
expect(failureMessage.exists()).toBe(true);
expect(failureMessage.text()).toContain(
'Update failed. Please check the logs and try again.',
);
});
it('displays a success toast message if application update was successful', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
it('displays a success toast message if application update was successful', async () => {
mountComponent({
title: 'GitLab Runner',
updateSuccessful: false,
});
vm.$toast = { show: jest.fn() };
vm.updateSuccessful = true;
wrapper.vm.$toast = { show: jest.fn() };
wrapper.setProps({ updateSuccessful: true });
return vm.$nextTick(() => {
expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner updated successfully.');
});
await wrapper.vm.$nextTick();
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('GitLab Runner updated successfully.');
});
describe('when updating does not require confirmation', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(ApplicationRow, {
propsData: {
...DEFAULT_APPLICATION_STATE,
updateAvailable: true,
},
});
});
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => mountComponent({ updateAvailable: true }));
it('the modal is not rendered', () => {
expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false);
@ -378,23 +292,14 @@ describe('Application Row', () => {
});
describe('when updating requires confirmation', () => {
let wrapper;
beforeEach(() => {
wrapper = shallowMount(ApplicationRow, {
propsData: {
...DEFAULT_APPLICATION_STATE,
updateAvailable: true,
id: ELASTIC_STACK,
version: '1.1.2',
},
mountComponent({
updateAvailable: true,
id: ELASTIC_STACK,
version: '1.1.2',
});
});
afterEach(() => {
wrapper.destroy();
});
it('displays a modal', () => {
expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true);
});
@ -415,60 +320,37 @@ describe('Application Row', () => {
});
describe('updating Elastic Stack special case', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
});
it('needs confirmation if version is lower than 3.0.0', () => {
wrapper = shallowMount(ApplicationRow, {
propsData: {
...DEFAULT_APPLICATION_STATE,
updateAvailable: true,
id: ELASTIC_STACK,
version: '1.1.2',
},
mountComponent({
updateAvailable: true,
id: ELASTIC_STACK,
version: '1.1.2',
});
wrapper.vm.$nextTick(() => {
expect(wrapper.contains("[data-qa-selector='update_button_with_confirmation']")).toBe(
true,
);
expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true);
});
expect(wrapper.contains("[data-qa-selector='update_button_with_confirmation']")).toBe(true);
expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true);
});
it('does not need confirmation is version is 3.0.0', () => {
wrapper = shallowMount(ApplicationRow, {
propsData: {
...DEFAULT_APPLICATION_STATE,
updateAvailable: true,
id: ELASTIC_STACK,
version: '3.0.0',
},
mountComponent({
updateAvailable: true,
id: ELASTIC_STACK,
version: '3.0.0',
});
wrapper.vm.$nextTick(() => {
expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true);
expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false);
});
expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true);
expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false);
});
it('does not need confirmation if version is higher than 3.0.0', () => {
wrapper = shallowMount(ApplicationRow, {
propsData: {
...DEFAULT_APPLICATION_STATE,
updateAvailable: true,
id: ELASTIC_STACK,
version: '5.2.1',
},
mountComponent({
updateAvailable: true,
id: ELASTIC_STACK,
version: '5.2.1',
});
wrapper.vm.$nextTick(() => {
expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true);
expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false);
});
expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true);
expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false);
});
});
});
@ -476,63 +358,57 @@ describe('Application Row', () => {
describe('Version', () => {
it('displays a version number if application has been updated', () => {
const version = '0.1.45';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.INSTALLED,
updateSuccessful: true,
version,
});
const updateDetails = vm.$el.querySelector('.js-cluster-application-update-details');
const versionEl = vm.$el.querySelector('.js-cluster-application-update-version');
const updateDetails = wrapper.find('.js-cluster-application-update-details');
const versionEl = wrapper.find('.js-cluster-application-update-version');
expect(updateDetails.innerHTML).toContain('Updated');
expect(versionEl).not.toBe(null);
expect(versionEl.innerHTML).toContain(version);
expect(updateDetails.text()).toContain('Updated');
expect(versionEl.exists()).toBe(true);
expect(versionEl.text()).toContain(version);
});
it('contains a link to the chart repo if application has been updated', () => {
const version = '0.1.45';
const chartRepo = 'https://gitlab.com/gitlab-org/charts/gitlab-runner';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.INSTALLED,
updateSuccessful: true,
chartRepo,
version,
});
const versionEl = vm.$el.querySelector('.js-cluster-application-update-version');
const versionEl = wrapper.find('.js-cluster-application-update-version');
expect(versionEl.href).toEqual(chartRepo);
expect(versionEl.target).toEqual('_blank');
expect(versionEl.attributes('href')).toEqual(chartRepo);
expect(versionEl.props('target')).toEqual('_blank');
});
it('does not display a version number if application update failed', () => {
const version = '0.1.45';
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.INSTALLED,
updateFailed: true,
version,
});
const updateDetails = vm.$el.querySelector('.js-cluster-application-update-details');
const versionEl = vm.$el.querySelector('.js-cluster-application-update-version');
const updateDetails = wrapper.find('.js-cluster-application-update-details');
const versionEl = wrapper.find('.js-cluster-application-update-version');
expect(updateDetails.innerHTML).toContain('failed');
expect(versionEl).toBe(null);
expect(updateDetails.text()).toContain('failed');
expect(versionEl.exists()).toBe(false);
});
});
describe('Error block', () => {
const generalErrorMessage = () => wrapper.find('.js-cluster-application-general-error-message');
describe('when nothing fails', () => {
it('does not show error block', () => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
});
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
mountComponent();
expect(generalErrorMessage).toBeNull();
expect(generalErrorMessage().exists()).toBe(false);
});
});
@ -541,8 +417,7 @@ describe('Application Row', () => {
const requestReason = 'We broke the request 0.0';
beforeEach(() => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.ERROR,
statusReason,
requestReason,
@ -551,37 +426,28 @@ describe('Application Row', () => {
});
it('shows status reason if it is available', () => {
const statusErrorMessage = vm.$el.querySelector(
'.js-cluster-application-status-error-message',
);
const statusErrorMessage = wrapper.find('.js-cluster-application-status-error-message');
expect(statusErrorMessage.textContent.trim()).toEqual(statusReason);
expect(statusErrorMessage.text()).toEqual(statusReason);
});
it('shows request reason if it is available', () => {
const requestErrorMessage = vm.$el.querySelector(
'.js-cluster-application-request-error-message',
);
const requestErrorMessage = wrapper.find('.js-cluster-application-request-error-message');
expect(requestErrorMessage.textContent.trim()).toEqual(requestReason);
expect(requestErrorMessage.text()).toEqual(requestReason);
});
});
describe('when install fails', () => {
beforeEach(() => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.ERROR,
installFailed: true,
});
});
it('shows a general message indicating the installation failed', () => {
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
expect(generalErrorMessage.textContent.trim()).toEqual(
expect(generalErrorMessage().text()).toEqual(
`Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`,
);
});
@ -589,19 +455,14 @@ describe('Application Row', () => {
describe('when uninstall fails', () => {
beforeEach(() => {
vm = mountComponent(ApplicationRow, {
...DEFAULT_APPLICATION_STATE,
mountComponent({
status: APPLICATION_STATUS.ERROR,
uninstallFailed: true,
});
});
it('shows a general message indicating the uninstalling failed', () => {
const generalErrorMessage = vm.$el.querySelector(
'.js-cluster-application-general-error-message',
);
expect(generalErrorMessage.textContent.trim()).toEqual(
expect(generalErrorMessage().text()).toEqual(
`Something went wrong while uninstalling ${DEFAULT_APPLICATION_STATE.title}`,
);
});

View File

@ -1083,4 +1083,62 @@ describe('Actions Notes Store', () => {
);
});
});
describe('softDeleteDescriptionVersion', () => {
const endpoint = '/path/to/diff/1';
const payload = {
endpoint,
startingVersion: undefined,
versionId: 1,
};
describe('if response contains no errors', () => {
it('dispatches requestDeleteDescriptionVersion', done => {
axiosMock.onDelete(endpoint).replyOnce(200);
testAction(
actions.softDeleteDescriptionVersion,
payload,
{},
[],
[
{
type: 'requestDeleteDescriptionVersion',
},
{
type: 'receiveDeleteDescriptionVersion',
payload: payload.versionId,
},
],
done,
);
});
});
describe('if response contains errors', () => {
const errorMessage = 'Request failed with status code 503';
it('dispatches receiveDeleteDescriptionVersionError and throws an error', done => {
axiosMock.onDelete(endpoint).replyOnce(503);
testAction(
actions.softDeleteDescriptionVersion,
payload,
{},
[],
[
{
type: 'requestDeleteDescriptionVersion',
},
{
type: 'receiveDeleteDescriptionVersionError',
payload: new Error(errorMessage),
},
],
)
.then(() => done.fail('Expected error to be thrown'))
.catch(() => {
expect(Flash).toHaveBeenCalled();
done();
});
});
});
});
});

View File

@ -5,6 +5,7 @@ import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_
import EditArea from '~/static_site_editor/components/edit_area.vue';
import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue';
import EditHeader from '~/static_site_editor/components/edit_header.vue';
import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue';
import { sourceContentTitle as title, sourceContent as content, returnUrl } from '../mock_data';
@ -28,6 +29,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
const findEditHeader = () => wrapper.find(EditHeader);
const findRichContentEditor = () => wrapper.find(RichContentEditor);
const findPublishToolbar = () => wrapper.find(PublishToolbar);
const findUnsavedChangesConfirmDialog = () => wrapper.find(UnsavedChangesConfirmDialog);
beforeEach(() => {
buildWrapper();
@ -49,9 +51,16 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
it('renders publish toolbar', () => {
expect(findPublishToolbar().exists()).toBe(true);
expect(findPublishToolbar().props('returnUrl')).toBe(returnUrl);
expect(findPublishToolbar().props('savingChanges')).toBe(savingChanges);
expect(findPublishToolbar().props('saveable')).toBe(false);
expect(findPublishToolbar().props()).toMatchObject({
returnUrl,
savingChanges,
saveable: false,
});
});
it('renders unsaved changes confirm dialog', () => {
expect(findUnsavedChangesConfirmDialog().exists()).toBe(true);
expect(findUnsavedChangesConfirmDialog().props('modified')).toBe(false);
});
describe('when content changes', () => {
@ -61,10 +70,14 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
return wrapper.vm.$nextTick();
});
it('sets publish toolbar as saveable when content changes', () => {
it('sets publish toolbar as saveable', () => {
expect(findPublishToolbar().props('saveable')).toBe(true);
});
it('sets unsaved changes confirm dialog as modified', () => {
expect(findUnsavedChangesConfirmDialog().props('modified')).toBe(true);
});
it('sets publish toolbar as not saveable when content changes are rollback', () => {
findRichContentEditor().vm.$emit('input', content);

View File

@ -0,0 +1,44 @@
import { shallowMount } from '@vue/test-utils';
import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue';
describe('static_site_editor/components/unsaved_changes_confirm_dialog', () => {
let wrapper;
let event;
let returnValueSetter;
const buildWrapper = (propsData = {}) => {
wrapper = shallowMount(UnsavedChangesConfirmDialog, {
propsData,
});
};
beforeEach(() => {
event = new Event('beforeunload');
jest.spyOn(event, 'preventDefault');
returnValueSetter = jest.spyOn(event, 'returnValue', 'set');
});
afterEach(() => {
event.preventDefault.mockRestore();
returnValueSetter.mockRestore();
wrapper.destroy();
});
it('displays confirmation dialog when modified = true', () => {
buildWrapper({ modified: true });
window.dispatchEvent(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(returnValueSetter).toHaveBeenCalledWith('');
});
it('does not display confirmation dialog when modified = false', () => {
buildWrapper();
window.dispatchEvent(event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(returnValueSetter).not.toHaveBeenCalled();
});
});

View File

@ -48,18 +48,45 @@ describe Gitlab::Runtime do
before do
stub_const('::Puma', puma_type)
allow(puma_type).to receive_message_chain(:cli_config, :options).and_return(max_threads: 2)
stub_config(action_cable: { in_app: false })
end
it_behaves_like "valid runtime", :puma, 3
context "when ActionCable in-app mode is enabled" do
before do
stub_config(action_cable: { in_app: true, worker_pool_size: 3 })
end
it_behaves_like "valid runtime", :puma, 6
end
context "when ActionCable standalone is run" do
before do
stub_const('ACTION_CABLE_SERVER', true)
stub_config(action_cable: { worker_pool_size: 8 })
end
it_behaves_like "valid runtime", :puma, 11
end
end
context "unicorn" do
before do
stub_const('::Unicorn', Module.new)
stub_const('::Unicorn::HttpServer', Class.new)
stub_config(action_cable: { in_app: false })
end
it_behaves_like "valid runtime", :unicorn, 1
context "when ActionCable in-app mode is enabled" do
before do
stub_config(action_cable: { in_app: true, worker_pool_size: 3 })
end
it_behaves_like "valid runtime", :unicorn, 4
end
end
context "sidekiq" do
@ -105,17 +132,4 @@ describe Gitlab::Runtime do
it_behaves_like "valid runtime", :rails_runner, 1
end
context "action_cable" do
before do
stub_const('ACTION_CABLE_SERVER', true)
stub_const('::Puma', Module.new)
allow(Gitlab::Application).to receive_message_chain(:config, :action_cable, :worker_pool_size).and_return(8)
end
it "reports its maximum concurrency based on ActionCable's worker pool size" do
expect(subject.max_threads).to eq(9)
end
end
end