From d9f331328ab89d8423cb43ee9103f2a39b473d7f Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Tue, 9 Jun 2020 09:08:20 +0000 Subject: [PATCH] Add latest changes from gitlab-org/gitlab@master --- .gitlab/ci/rails.gitlab-ci.yml | 1 + .../behaviors/markdown/render_mermaid.js | 3 +- .../clusters/components/application_row.vue | 4 + .../mixins/description_version_history.js | 2 +- .../javascripts/notes/stores/actions.js | 4 + .../components/edit_area.vue | 3 + .../unsaved_changes_confirm_dialog.vue | 27 ++ .../components/notes/system_note.vue | 2 +- app/assets/stylesheets/page_bundles/ide.scss | 3 +- .../ide_themes/_solarized-dark.scss | 50 ++ app/models/concerns/timebox.rb | 2 +- .../unreleased/216865-confirm-leave-site.yml | 5 + ...d-web-ide-solarized-dark-theme-support.yml | 5 + config/application.rb | 15 - config/gitlab.yml.example | 3 + config/initializers/1_settings.rb | 1 + config/initializers/7_redis.rb | 11 + config/initializers/action_cable.rb | 6 +- .../reference_architectures/index.md | 13 +- lib/gitlab/runtime.rb | 26 +- qa/qa/resource/project.rb | 2 +- qa/qa/runtime/api/repository_storage_moves.rb | 4 +- .../changing_repository_storage_spec.rb | 53 +++ ...k_status_of_changing_repository_storage.rb | 33 -- .../merge_merge_request_from_fork_spec.rb | 2 +- .../components/application_row_spec.js | 449 ++++++------------ spec/frontend/notes/stores/actions_spec.js | 58 +++ .../components/edit_area_spec.js | 21 +- .../unsaved_changes_confirm_dialog_spec.js | 44 ++ spec/lib/gitlab/runtime_spec.rb | 40 +- 30 files changed, 501 insertions(+), 391 deletions(-) create mode 100644 app/assets/javascripts/static_site_editor/components/unsaved_changes_confirm_dialog.vue create mode 100644 app/assets/stylesheets/page_bundles/ide_themes/_solarized-dark.scss create mode 100644 changelogs/unreleased/216865-confirm-leave-site.yml create mode 100644 changelogs/unreleased/219228-add-web-ide-solarized-dark-theme-support.yml create mode 100644 qa/qa/specs/features/api/3_create/repository/changing_repository_storage_spec.rb delete mode 100644 qa/qa/specs/features/api/3_create/repository/check_status_of_changing_repository_storage.rb create mode 100644 spec/frontend/static_site_editor/components/unsaved_changes_confirm_dialog_spec.js diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index fa69cc47d08..9aa5c740e2e 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -360,5 +360,6 @@ rspec foss-impact: expire_in: 7d paths: - tmp/matching_foss_tests.txt + - tmp/capybara/ # EE: Merge Request pipelines ################################################## diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 057cdb6cc4c..e4c69a114e0 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -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') ) { diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index 382ff50ebee..3dcb96a6c54 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -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, diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js index 66e6685cfd8..d1006e37a70 100644 --- a/app/assets/javascripts/notes/mixins/description_version_history.js +++ b/app/assets/javascripts/notes/mixins/description_version_history.js @@ -3,7 +3,7 @@ export default { computed: { canSeeDescriptionVersion() {}, - canDeleteDescriptionVersion() {}, + displayDeleteButton() {}, shouldShowDescriptionVersion() {}, descriptionVersionToggleIcon() {}, }, diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index e1a7fb86501..b441861b5de 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -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(); }); }; diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index ca81d4891a1..33669c8faef 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -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 {
+ +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, +}; + diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index ec7d7e94e5c..b6271a95008 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -132,7 +132,7 @@ export default {

            {
-  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}`,
         );
       });
diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js
index fa375697b9d..2fef0884516 100644
--- a/spec/frontend/notes/stores/actions_spec.js
+++ b/spec/frontend/notes/stores/actions_spec.js
@@ -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();
+          });
+      });
+    });
+  });
 });
diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js
index bfe41f65d6e..5817bad2ac5 100644
--- a/spec/frontend/static_site_editor/components/edit_area_spec.js
+++ b/spec/frontend/static_site_editor/components/edit_area_spec.js
@@ -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);
 
diff --git a/spec/frontend/static_site_editor/components/unsaved_changes_confirm_dialog_spec.js b/spec/frontend/static_site_editor/components/unsaved_changes_confirm_dialog_spec.js
new file mode 100644
index 00000000000..9b8b22da693
--- /dev/null
+++ b/spec/frontend/static_site_editor/components/unsaved_changes_confirm_dialog_spec.js
@@ -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();
+  });
+});
diff --git a/spec/lib/gitlab/runtime_spec.rb b/spec/lib/gitlab/runtime_spec.rb
index 8f920bb2e01..93f24873b96 100644
--- a/spec/lib/gitlab/runtime_spec.rb
+++ b/spec/lib/gitlab/runtime_spec.rb
@@ -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