From bf229a6c632709ef7c69a7033eadfd74f5ddcc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Alc=C3=A1ntara?= Date: Thu, 2 May 2019 14:17:03 +0000 Subject: Uninstall application confirm modal component - Vue confirmation modal implementation - CSS tweaks for modal default height --- spec/frontend/clusters/clusters_bundle_spec.js | 82 +++++------ .../clusters/components/application_row_spec.js | 158 +++++++++++++++------ .../uninstall_application_button_spec.js | 32 +++++ ...ninstall_application_confirmation_modal_spec.js | 47 ++++++ .../services/application_state_machine_spec.js | 54 +++++-- spec/frontend/clusters/services/mock_data.js | 7 + .../clusters/stores/clusters_store_spec.js | 14 ++ 7 files changed, 291 insertions(+), 103 deletions(-) create mode 100644 spec/frontend/clusters/components/uninstall_application_button_spec.js create mode 100644 spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js (limited to 'spec/frontend/clusters') diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index a61103397eb..73897107f67 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -1,12 +1,12 @@ import Clusters from '~/clusters/clusters_bundle'; -import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX } from '~/clusters/constants'; +import { APPLICATION_STATUS, INGRESS_DOMAIN_SUFFIX, APPLICATIONS } from '~/clusters/constants'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import { loadHTMLFixture } from 'helpers/fixtures'; import { setTestTimeout } from 'helpers/timeout'; import $ from 'jquery'; -const { INSTALLING, INSTALLABLE, INSTALLED } = APPLICATION_STATUS; +const { INSTALLING, INSTALLABLE, INSTALLED, UNINSTALLING } = APPLICATION_STATUS; describe('Clusters', () => { setTestTimeout(1000); @@ -212,73 +212,61 @@ describe('Clusters', () => { }); describe('installApplication', () => { - it('tries to install helm', () => { + it.each(APPLICATIONS)('tries to install %s', applicationId => { jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - cluster.store.state.applications.helm.status = INSTALLABLE; + cluster.store.state.applications[applicationId].status = INSTALLABLE; - cluster.installApplication({ id: 'helm' }); + cluster.installApplication({ id: applicationId }); - expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING); - expect(cluster.store.state.applications.helm.requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith('helm', undefined); + expect(cluster.store.state.applications[applicationId].status).toEqual(INSTALLING); + expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith(applicationId, undefined); }); - it('tries to install ingress', () => { - jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); - - cluster.store.state.applications.ingress.status = INSTALLABLE; - - cluster.installApplication({ id: 'ingress' }); - - expect(cluster.store.state.applications.ingress.status).toEqual(INSTALLING); - expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress', undefined); - }); + it('sets error request status when the request fails', () => { + jest + .spyOn(cluster.service, 'installApplication') + .mockRejectedValueOnce(new Error('STUBBED ERROR')); - it('tries to install runner', () => { - jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); + cluster.store.state.applications.helm.status = INSTALLABLE; - cluster.store.state.applications.runner.status = INSTALLABLE; + const promise = cluster.installApplication({ id: 'helm' }); - cluster.installApplication({ id: 'runner' }); + return promise.then(() => { + expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE); + expect(cluster.store.state.applications.helm.installFailed).toBe(true); - expect(cluster.store.state.applications.runner.status).toEqual(INSTALLING); - expect(cluster.store.state.applications.runner.requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith('runner', undefined); + expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); + }); }); + }); - it('tries to install jupyter', () => { - jest.spyOn(cluster.service, 'installApplication').mockResolvedValueOnce(); + describe('uninstallApplication', () => { + it.each(APPLICATIONS)('tries to uninstall %s', applicationId => { + jest.spyOn(cluster.service, 'uninstallApplication').mockResolvedValueOnce(); - cluster.installApplication({ - id: 'jupyter', - params: { hostname: cluster.store.state.applications.jupyter.hostname }, - }); + cluster.store.state.applications[applicationId].status = INSTALLED; - cluster.store.state.applications.jupyter.status = INSTALLABLE; - expect(cluster.store.state.applications.jupyter.requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalledWith('jupyter', { - hostname: cluster.store.state.applications.jupyter.hostname, - }); + cluster.uninstallApplication({ id: applicationId }); + + expect(cluster.store.state.applications[applicationId].status).toEqual(UNINSTALLING); + expect(cluster.store.state.applications[applicationId].requestReason).toEqual(null); + expect(cluster.service.uninstallApplication).toHaveBeenCalledWith(applicationId); }); - it('sets error request status when the request fails', () => { + it('sets error request status when the uninstall request fails', () => { jest - .spyOn(cluster.service, 'installApplication') + .spyOn(cluster.service, 'uninstallApplication') .mockRejectedValueOnce(new Error('STUBBED ERROR')); - cluster.store.state.applications.helm.status = INSTALLABLE; + cluster.store.state.applications.helm.status = INSTALLED; - const promise = cluster.installApplication({ id: 'helm' }); - - expect(cluster.store.state.applications.helm.status).toEqual(INSTALLING); - expect(cluster.store.state.applications.helm.requestReason).toEqual(null); - expect(cluster.service.installApplication).toHaveBeenCalled(); + const promise = cluster.uninstallApplication({ id: 'helm' }); return promise.then(() => { - expect(cluster.store.state.applications.helm.status).toEqual(INSTALLABLE); - expect(cluster.store.state.applications.helm.installFailed).toBe(true); + expect(cluster.store.state.applications.helm.status).toEqual(INSTALLED); + expect(cluster.store.state.applications.helm.uninstallFailed).toBe(true); expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); }); diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js index 17273b7d5b1..7c781b72355 100644 --- a/spec/frontend/clusters/components/application_row_spec.js +++ b/spec/frontend/clusters/components/application_row_spec.js @@ -1,7 +1,10 @@ import Vue from 'vue'; +import { shallowMount } from '@vue/test-utils'; import eventHub from '~/clusters/event_hub'; import { APPLICATION_STATUS } from '~/clusters/constants'; import applicationRow from '~/clusters/components/application_row.vue'; +import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue'; + import mountComponent from 'helpers/vue_mount_component_helper'; import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; @@ -194,11 +197,52 @@ describe('Application Row', () => { ...DEFAULT_APPLICATION_STATE, installed: true, uninstallable: true, + status: APPLICATION_STATUS.NOT_INSTALLABLE, }); const uninstallButton = vm.$el.querySelector('.js-cluster-application-uninstall-button'); expect(uninstallButton).toBeTruthy(); }); + + it('displays a success toast message if application uninstall was successful', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + title: 'GitLab Runner', + uninstallSuccessful: false, + }); + + vm.$toast = { show: jest.fn() }; + vm.uninstallSuccessful = true; + + return vm.$nextTick(() => { + expect(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'); + wrapper.find(UninstallApplicationConfirmationModal).vm.$emit('confirm'); + + expect(eventHub.$emit).toHaveBeenCalledWith('uninstallApplication', { + id: DEFAULT_APPLICATION_STATE.id, + }); + }); }); describe('Upgrade button', () => { @@ -304,7 +348,7 @@ describe('Application Row', () => { vm.$toast = { show: jest.fn() }; vm.updateSuccessful = true; - vm.$nextTick(() => { + return vm.$nextTick(() => { expect(vm.$toast.show).toHaveBeenCalledWith('GitLab Runner upgraded successfully.'); }); }); @@ -360,60 +404,88 @@ describe('Application Row', () => { }); describe('Error block', () => { - it('does not show error block when there is no error', () => { - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: null, - }); - const generalErrorMessage = vm.$el.querySelector( - '.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', + ); - expect(generalErrorMessage).toBeNull(); + expect(generalErrorMessage).toBeNull(); + }); }); - it('shows status reason when install fails', () => { + describe('when install or uninstall fails', () => { const statusReason = 'We broke it 0.0'; - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.ERROR, - statusReason, - installFailed: true, + const requestReason = 'We broke the request 0.0'; + + beforeEach(() => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.ERROR, + statusReason, + requestReason, + installFailed: true, + }); }); - const generalErrorMessage = vm.$el.querySelector( - '.js-cluster-application-general-error-message', - ); - const statusErrorMessage = vm.$el.querySelector( - '.js-cluster-application-status-error-message', - ); - expect(generalErrorMessage.textContent.trim()).toEqual( - `Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`, - ); + it('shows status reason if it is available', () => { + const statusErrorMessage = vm.$el.querySelector( + '.js-cluster-application-status-error-message', + ); + + expect(statusErrorMessage.textContent.trim()).toEqual(statusReason); + }); + + it('shows request reason if it is available', () => { + const requestErrorMessage = vm.$el.querySelector( + '.js-cluster-application-request-error-message', + ); + + expect(requestErrorMessage.textContent.trim()).toEqual(requestReason); + }); + }); + + describe('when install fails', () => { + beforeEach(() => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.ERROR, + installFailed: true, + }); + }); - expect(statusErrorMessage.textContent.trim()).toEqual(statusReason); + 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( + `Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`, + ); + }); }); - it('shows request reason when REQUEST_FAILURE', () => { - const requestReason = 'We broke thre request 0.0'; - vm = mountComponent(ApplicationRow, { - ...DEFAULT_APPLICATION_STATE, - status: APPLICATION_STATUS.INSTALLABLE, - installFailed: true, - requestReason, + describe('when uninstall fails', () => { + beforeEach(() => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_STATUS.ERROR, + uninstallFailed: true, + }); }); - const generalErrorMessage = vm.$el.querySelector( - '.js-cluster-application-general-error-message', - ); - const requestErrorMessage = vm.$el.querySelector( - '.js-cluster-application-request-error-message', - ); - expect(generalErrorMessage.textContent.trim()).toEqual( - `Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`, - ); + it('shows a general message indicating the uninstalling failed', () => { + const generalErrorMessage = vm.$el.querySelector( + '.js-cluster-application-general-error-message', + ); - expect(requestErrorMessage.textContent.trim()).toEqual(requestReason); + expect(generalErrorMessage.textContent.trim()).toEqual( + `Something went wrong while uninstalling ${DEFAULT_APPLICATION_STATE.title}`, + ); + }); }); }); }); diff --git a/spec/frontend/clusters/components/uninstall_application_button_spec.js b/spec/frontend/clusters/components/uninstall_application_button_spec.js new file mode 100644 index 00000000000..9f9397d4d41 --- /dev/null +++ b/spec/frontend/clusters/components/uninstall_application_button_spec.js @@ -0,0 +1,32 @@ +import { shallowMount } from '@vue/test-utils'; +import UninstallApplicationButton from '~/clusters/components/uninstall_application_button.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { APPLICATION_STATUS } from '~/clusters/constants'; + +const { INSTALLED, UPDATING, UNINSTALLING } = APPLICATION_STATUS; + +describe('UninstallApplicationButton', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(UninstallApplicationButton, { + propsData: { ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each` + status | loading | disabled | label + ${INSTALLED} | ${false} | ${false} | ${'Uninstall'} + ${UPDATING} | ${false} | ${true} | ${'Uninstall'} + ${UNINSTALLING} | ${true} | ${true} | ${'Uninstalling'} + `('when app status is $status', ({ loading, disabled, status, label }) => { + it(`renders a loading=${loading}, disabled=${disabled} button with label="${label}"`, () => { + createComponent({ status }); + expect(wrapper.find(LoadingButton).props()).toMatchObject({ loading, disabled, label }); + }); + }); +}); diff --git a/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js b/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js new file mode 100644 index 00000000000..6a7126b45cd --- /dev/null +++ b/spec/frontend/clusters/components/uninstall_application_confirmation_modal_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import UninstallApplicationConfirmationModal from '~/clusters/components/uninstall_application_confirmation_modal.vue'; +import { GlModal } from '@gitlab/ui'; +import { INGRESS } from '~/clusters/constants'; + +describe('UninstallApplicationConfirmationModal', () => { + let wrapper; + const appTitle = 'Ingress'; + + const createComponent = (props = {}) => { + wrapper = shallowMount(UninstallApplicationConfirmationModal, { + propsData: { ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + beforeEach(() => { + createComponent({ application: INGRESS, applicationTitle: appTitle }); + }); + + it(`renders a modal with a title "Uninstall ${appTitle}"`, () => { + expect(wrapper.find(GlModal).attributes('title')).toEqual(`Uninstall ${appTitle}`); + }); + + it(`renders a modal with an ok button labeled "Uninstall ${appTitle}"`, () => { + expect(wrapper.find(GlModal).attributes('ok-title')).toEqual(`Uninstall ${appTitle}`); + }); + + it('triggers confirm event when ok button is clicked', () => { + wrapper.find(GlModal).vm.$emit('ok'); + + expect(wrapper.emitted('confirm')).toBeTruthy(); + }); + + it('displays a warning text indicating the app will be uninstalled', () => { + expect(wrapper.text()).toContain(`You are about to uninstall ${appTitle} from your cluster.`); + }); + + it('displays a custom warning text depending on the application', () => { + expect(wrapper.text()).toContain( + `The associated load balancer and IP will be deleted and cannot be restored.`, + ); + }); +}); diff --git a/spec/frontend/clusters/services/application_state_machine_spec.js b/spec/frontend/clusters/services/application_state_machine_spec.js index e74b7910572..e057e2ac955 100644 --- a/spec/frontend/clusters/services/application_state_machine_spec.js +++ b/spec/frontend/clusters/services/application_state_machine_spec.js @@ -1,5 +1,10 @@ import transitionApplicationState from '~/clusters/services/application_state_machine'; -import { APPLICATION_STATUS, UPDATE_EVENT, INSTALL_EVENT } from '~/clusters/constants'; +import { + APPLICATION_STATUS, + UNINSTALL_EVENT, + UPDATE_EVENT, + INSTALL_EVENT, +} from '~/clusters/constants'; const { NO_STATUS, @@ -12,6 +17,8 @@ const { UPDATING, UPDATED, UPDATE_ERRORED, + UNINSTALLING, + UNINSTALL_ERRORED, } = APPLICATION_STATUS; const NO_EFFECTS = 'no effects'; @@ -21,16 +28,18 @@ describe('applicationStateMachine', () => { describe(`current state is ${NO_STATUS}`, () => { it.each` - expectedState | event | effects - ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS} - ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} - ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} - ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS} - ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} - ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} - ${UPDATING} | ${UPDATING} | ${NO_EFFECTS} - ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS} - ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} + expectedState | event | effects + ${INSTALLING} | ${SCHEDULED} | ${NO_EFFECTS} + ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} + ${INSTALLABLE} | ${INSTALLABLE} | ${NO_EFFECTS} + ${INSTALLING} | ${INSTALLING} | ${NO_EFFECTS} + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} + ${UPDATING} | ${UPDATING} | ${NO_EFFECTS} + ${INSTALLED} | ${UPDATED} | ${NO_EFFECTS} + ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} + ${UNINSTALLING} | ${UNINSTALLING} | ${NO_EFFECTS} + ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }} `(`transitions to $expectedState on $event event and applies $effects`, data => { const { expectedState, event, effects } = data; const currentAppState = { @@ -99,8 +108,9 @@ describe('applicationStateMachine', () => { describe(`current state is ${INSTALLED}`, () => { it.each` - expectedState | event | effects - ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }} + expectedState | event | effects + ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }} + ${UNINSTALLING} | ${UNINSTALL_EVENT} | ${{ uninstallFailed: false, uninstallSuccessful: false }} `(`transitions to $expectedState on $event event and applies $effects`, data => { const { expectedState, event, effects } = data; const currentAppState = { @@ -131,4 +141,22 @@ describe('applicationStateMachine', () => { }); }); }); + + describe(`current state is ${UNINSTALLING}`, () => { + it.each` + expectedState | event | effects + ${INSTALLABLE} | ${INSTALLABLE} | ${{ uninstallSuccessful: true }} + ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: UNINSTALLING, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...effects, + }); + }); + }); }); diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index 1e896af1c7d..41ad398e924 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -11,6 +11,7 @@ const CLUSTERS_MOCK_DATA = { name: 'helm', status: APPLICATION_STATUS.INSTALLABLE, status_reason: null, + can_uninstall: false, }, { name: 'ingress', @@ -18,32 +19,38 @@ const CLUSTERS_MOCK_DATA = { status_reason: 'Cannot connect', external_ip: null, external_hostname: null, + can_uninstall: false, }, { name: 'runner', status: APPLICATION_STATUS.INSTALLING, status_reason: null, + can_uninstall: false, }, { name: 'prometheus', status: APPLICATION_STATUS.ERROR, status_reason: 'Cannot connect', + can_uninstall: false, }, { name: 'jupyter', status: APPLICATION_STATUS.INSTALLING, status_reason: 'Cannot connect', + can_uninstall: false, }, { name: 'knative', status: APPLICATION_STATUS.INSTALLING, status_reason: 'Cannot connect', + can_uninstall: false, }, { name: 'cert_manager', status: APPLICATION_STATUS.ERROR, status_reason: 'Cannot connect', email: 'test@example.com', + can_uninstall: false, }, ], }, diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index a20e0439555..aa926bb36d7 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -63,6 +63,8 @@ describe('Clusters Store', () => { installed: false, installFailed: false, uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, }, ingress: { title: 'Ingress', @@ -74,6 +76,8 @@ describe('Clusters Store', () => { installed: false, installFailed: true, uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, }, runner: { title: 'GitLab Runner', @@ -89,6 +93,8 @@ describe('Clusters Store', () => { updateFailed: false, updateSuccessful: false, uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, }, prometheus: { title: 'Prometheus', @@ -98,6 +104,8 @@ describe('Clusters Store', () => { installed: false, installFailed: true, uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, }, jupyter: { title: 'JupyterHub', @@ -108,6 +116,8 @@ describe('Clusters Store', () => { installed: false, installFailed: false, uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, }, knative: { title: 'Knative', @@ -121,6 +131,8 @@ describe('Clusters Store', () => { installed: false, installFailed: false, uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, }, cert_manager: { title: 'Cert-Manager', @@ -131,6 +143,8 @@ describe('Clusters Store', () => { email: mockResponseData.applications[6].email, installed: false, uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, }, }, }); -- cgit v1.2.3