From 55055d0b9ba0c4ef81d871282b76dbab9e38c0de Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Wed, 7 Dec 2022 16:15:33 +0100 Subject: [PATCH] Alerting: Fix RuleEditor flaky test for existing grafana managed alert (#59780) * Fix RuleEditor flaky test for existing grafana managed alert * Fix RuleEditor flaky test for creating a new grafana managed alert * Fix tests for cloud rules * Split tests for different rule types * Move common clickSelectOption method to helpers * Remove from RuleEditorCloudRules.test.tsx a repeated cloud rule test * Move existing grafana rule test case to a new file * Split RuleEditorCloudRules test: move checking only allowed data sources test case to a new test file * Re-use common ui const * Re-use renderRuleEditor method * Create getDiscoverFeaturesMock to reduce code --- .betterer.results | 3 - .../alerting/unified/RuleEditor.test.tsx | 687 ------------------ .../RuleEditorCloudOnlyAllowed.test.tsx | 183 +++++ .../unified/RuleEditorCloudRules.test.tsx | 153 ++++ .../unified/RuleEditorExisting.test.tsx | 219 ++++++ .../unified/RuleEditorGrafanaRules.test.tsx | 169 +++++ .../unified/RuleEditorRecordingRule.test.tsx | 160 ++++ public/test/helpers/alertingRuleEditor.tsx | 50 ++ public/test/helpers/selectOptionInTest.ts | 13 +- 9 files changed, 946 insertions(+), 691 deletions(-) delete mode 100644 public/app/features/alerting/unified/RuleEditor.test.tsx create mode 100644 public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx create mode 100644 public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx create mode 100644 public/app/features/alerting/unified/RuleEditorExisting.test.tsx create mode 100644 public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx create mode 100644 public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx create mode 100644 public/test/helpers/alertingRuleEditor.tsx diff --git a/.betterer.results b/.betterer.results index 8cf81d8d286..b3c1494e10e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2907,9 +2907,6 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "2"], [0, 0, 0, "Unexpected any. Specify a different type.", "3"] ], - "public/app/features/alerting/unified/RuleEditor.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/alerting/unified/RuleList.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx deleted file mode 100644 index bad0f3cc75d..00000000000 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ /dev/null @@ -1,687 +0,0 @@ -import { Matcher, render, waitFor, screen, within } from '@testing-library/react'; -import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; -import React from 'react'; -import { Provider } from 'react-redux'; -import { Route, Router } from 'react-router-dom'; -import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; -import { byRole, byTestId, byText } from 'testing-library-selector'; - -import { DataSourceInstanceSettings } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { locationService, setDataSourceSrv } from '@grafana/runtime'; -import { ADD_NEW_FOLER_OPTION } from 'app/core/components/Select/FolderPicker'; -import { contextSrv } from 'app/core/services/context_srv'; -import { DashboardSearchHit } from 'app/features/search/types'; -import { configureStore } from 'app/store/configureStore'; -import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; - -import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; -import { backendSrv } from '../../../core/services/backend_srv'; -import { AccessControlAction } from '../../../types'; - -import RuleEditor from './RuleEditor'; -import { discoverFeatures } from './api/buildInfo'; -import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; -import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; -import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks'; -import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; -import * as config from './utils/config'; -import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; -import { getDefaultQueries } from './utils/rule-form'; - -jest.mock('./components/rule-editor/ExpressionEditor', () => ({ - // eslint-disable-next-line react/display-name - ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( - onChange(e.target.value)} /> - ), -})); - -jest.mock('./api/buildInfo'); -jest.mock('./api/ruler'); -jest.mock('../../../../app/features/manage-dashboards/state/actions'); - -// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. -// lets just skip it -jest.mock('app/features/query/components/QueryEditorRow', () => ({ - // eslint-disable-next-line react/display-name - QueryEditorRow: () =>

hi

, -})); - -jest.spyOn(config, 'getAllDataSources'); - -const mocks = { - getAllDataSources: jest.mocked(config.getAllDataSources), - searchFolders: jest.mocked(searchFolders), - api: { - discoverFeatures: jest.mocked(discoverFeatures), - fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), - setRulerRuleGroup: jest.mocked(setRulerRuleGroup), - fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), - fetchRulerRules: jest.mocked(fetchRulerRules), - fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), - }, -}; - -function renderRuleEditor(identifier?: string) { - const store = configureStore(); - - locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`); - - return render( - - - - - - ); -} - -const ui = { - inputs: { - name: byRole('textbox', { name: /rule name name for the alert rule\./i }), - alertType: byTestId('alert-type-picker'), - dataSource: byTestId('datasource-picker'), - folder: byTestId('folder-picker'), - folderContainer: byTestId(selectors.components.FolderPicker.containerV2), - namespace: byTestId('namespace-picker'), - group: byTestId('group-picker'), - annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`), - annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), - labelKey: (idx: number) => byTestId(`label-key-${idx}`), - labelValue: (idx: number) => byTestId(`label-value-${idx}`), - expr: byTestId('expr'), - }, - buttons: { - save: byRole('button', { name: 'Save' }), - addAnnotation: byRole('button', { name: /Add info/ }), - addLabel: byRole('button', { name: /Add label/ }), - // alert type buttons - grafanaManagedAlert: byRole('button', { name: /Grafana managed/ }), - lotexAlert: byRole('button', { name: /Mimir or Loki alert/ }), - lotexRecordingRule: byRole('button', { name: /Mimir or Loki recording rule/ }), - }, -}; - -const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); - -// Until flakiness is fixed -// https://github.com/grafana/grafana/issues/58747 -describe.skip('RuleEditor', () => { - beforeEach(() => { - jest.clearAllMocks(); - contextSrv.isEditor = true; - contextSrv.hasEditPermissionInFolders = true; - }); - - disableRBAC(); - - it('can create a new cloud alert', async () => { - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRulesGroup.mockResolvedValue({ - name: 'group2', - rules: [], - }); - mocks.api.fetchRulerRules.mockResolvedValue({ - namespace1: [ - { - name: 'group1', - rules: [], - }, - ], - namespace2: [ - { - name: 'group2', - rules: [], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([]); - - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Cortex, - features: { - rulerApiEnabled: true, - }, - }); - - await renderRuleEditor(); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - - await userEvent.click(await ui.buttons.lotexAlert.find()); - - const dataSourceSelect = ui.inputs.dataSource.get(); - await userEvent.click(byRole('combobox').get(dataSourceSelect)); - await clickSelectOption(dataSourceSelect, 'Prom (default)'); - await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); - - await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); - - await userEvent.type(ui.inputs.name.get(), 'my great new rule'); - await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); - await clickSelectOption(ui.inputs.group.get(), 'group2'); - - await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary'); - await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - - await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); - - // save and check what was sent to backend - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: 'Prom', apiVersion: 'legacy' }, - 'namespace2', - { - name: 'group2', - rules: [ - { - alert: 'my great new rule', - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - expr: 'up == 1', - for: '1m', - }, - ], - } - ); - }); - - it('can create new grafana managed alert', async () => { - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRulesGroup.mockResolvedValue({ - name: 'group2', - rules: [], - }); - mocks.api.fetchRulerRules.mockResolvedValue({ - 'Folder A': [ - { - name: 'group1', - rules: [], - }, - ], - namespace2: [ - { - name: 'group2', - rules: [], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([ - { - title: 'Folder A', - id: 1, - }, - { - title: 'Folder B', - id: 2, - }, - { - title: 'Folder / with slash', - id: 2, - }, - ] as DashboardSearchHit[]); - - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Prometheus, - features: { - rulerApiEnabled: false, - }, - }); - - // fill out the form - await renderRuleEditor(); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - - await userEvent.type(await ui.inputs.name.find(), 'my great new rule'); - - const folderInput = await ui.inputs.folder.find(); - await clickSelectOption(folderInput, 'Folder A'); - const groupInput = await ui.inputs.group.find(); - await userEvent.click(byRole('combobox').get(groupInput)); - await clickSelectOption(groupInput, 'group1 (1m)'); - - await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary'); - await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - - await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); - - // save and check what was sent to backend - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'Folder A', - { - interval: '1m', - name: 'group1', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '5m', - grafana_alert: { - condition: 'C', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: 'NoData', - title: 'my great new rule', - }, - }, - ], - } - ); - }); - - it('can create a new cloud recording rule', async () => { - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRulesGroup.mockResolvedValue({ - name: 'group2', - rules: [], - }); - mocks.api.fetchRulerRules.mockResolvedValue({ - namespace1: [ - { - name: 'group1', - rules: [], - }, - ], - namespace2: [ - { - name: 'group2', - rules: [], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([]); - - mocks.api.discoverFeatures.mockResolvedValue({ - application: PromApplication.Cortex, - features: { - rulerApiEnabled: true, - }, - }); - - await renderRuleEditor(); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); - await userEvent.click(await ui.buttons.lotexRecordingRule.get()); - - const dataSourceSelect = ui.inputs.dataSource.get(); - await userEvent.click(byRole('combobox').get(dataSourceSelect)); - - await clickSelectOption(dataSourceSelect, 'Prom (default)'); - await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); - await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); - await clickSelectOption(ui.inputs.group.get(), 'group2'); - - await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); - - // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed - await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); - - await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); - - // try to save, find out that recording rule name is invalid - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => - expect( - byText( - 'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.' - ).get() - ).toBeInTheDocument() - ); - expect(mocks.api.setRulerRuleGroup).not.toBeCalled(); - - // fix name and re-submit - await userEvent.clear(await ui.inputs.name.find()); - await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule'); - - // save and check what was sent to backend - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: 'Prom', apiVersion: 'legacy' }, - 'namespace2', - { - name: 'group2', - rules: [ - { - record: 'my:great:new:recording:rule', - labels: { team: 'the a-team' }, - expr: 'up == 1', - }, - ], - } - ); - }); - - it('can edit grafana managed rule', async () => { - const uid = 'FOOBAR123'; - const folder = { - title: 'Folder A', - uid: 'abcd', - id: 1, - }; - - const slashedFolder = { - title: 'Folder with /', - uid: 'abcde', - id: 2, - }; - - const dataSources = { - default: mockDataSource( - { - type: 'prometheus', - name: 'Prom', - isDefault: true, - }, - { alerting: true } - ), - }; - - jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ - ...mockFolder(), - accessControl: { - [AccessControlAction.AlertingRuleUpdate]: true, - }, - }); - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.api.setRulerRuleGroup.mockResolvedValue(); - mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); - mocks.api.fetchRulerRules.mockResolvedValue({ - [folder.title]: [ - { - interval: '1m', - name: 'my great new rule', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary' }, - labels: { severity: 'warn', team: 'the a-team' }, - for: '5m', - grafana_alert: { - uid, - namespace_uid: 'abcd', - namespace_id: 1, - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: GrafanaAlertStateDecision.NoData, - title: 'my great new rule', - }, - }, - ], - }, - ], - }); - mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]); - - await renderRuleEditor(uid); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - - // check that it's filled in - const nameInput = await ui.inputs.name.find(); - expect(nameInput).toHaveValue('my great new rule'); - //check that folder is in the list - expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); - expect(ui.inputs.annotationValue(0).get()).toHaveValue('some description'); - expect(ui.inputs.annotationValue(1).get()).toHaveValue('some summary'); - - //check that slashed folders are not in the list - expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); - expect(ui.inputs.folder.get()).not.toHaveTextContent(new RegExp(slashedFolder.title)); - - //check that slashes warning is only shown once user search slashes - const folderInput = await ui.inputs.folderContainer.find(); - expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); - await userEvent.type(within(folderInput).getByRole('combobox'), 'new slashed //'); - expect(within(folderInput).getByText("Folders with '/' character are not allowed.")).toBeInTheDocument(); - await userEvent.keyboard('{backspace} {backspace}{backspace}'); - expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); - - // add an annotation - await clickSelectOption(ui.inputs.annotationKey(2).get(), /Add new/); - await userEvent.type(byRole('textbox').get(ui.inputs.annotationKey(2).get()), 'custom'); - await userEvent.type(ui.inputs.annotationValue(2).get(), 'value'); - - //add a label - await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}'); - await userEvent.type(getLabelInput(ui.inputs.labelValue(2).get()), 'value{enter}'); - - // save and check what was sent to backend - await userEvent.click(ui.buttons.save.get()); - await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); - - //check that '+ Add new' option is in folders drop down even if we don't have values - const emptyFolderInput = await ui.inputs.folderContainer.find(); - mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]); - await renderRuleEditor(uid); - await userEvent.click(within(emptyFolderInput).getByRole('combobox')); - expect(screen.getByText(ADD_NEW_FOLER_OPTION)).toBeInTheDocument(); - - expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( - { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, - 'Folder A', - { - interval: '1m', - name: 'my great new rule', - rules: [ - { - annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, - labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, - for: '5m', - grafana_alert: { - uid, - condition: 'B', - data: getDefaultQueries(), - exec_err_state: GrafanaAlertStateDecision.Error, - no_data_state: 'NoData', - title: 'my great new rule', - }, - }, - ], - } - ); - }); - - it('for cloud alerts, should only allow to select editable rules sources', async () => { - const dataSources: Record> = { - // can edit rules - loki: mockDataSource( - { - type: DataSourceType.Loki, - name: 'loki with ruler', - }, - { alerting: true } - ), - loki_disabled: mockDataSource( - { - type: DataSourceType.Loki, - name: 'loki disabled for alerting', - jsonData: { - manageAlerts: false, - }, - }, - { alerting: true } - ), - // can edit rules - prom: mockDataSource( - { - type: DataSourceType.Prometheus, - name: 'cortex with ruler', - }, - { alerting: true } - ), - // cannot edit rules - loki_local_rule_store: mockDataSource( - { - type: DataSourceType.Loki, - name: 'loki with local rule store', - }, - { alerting: true } - ), - // cannot edit rules - prom_no_ruler_api: mockDataSource( - { - type: DataSourceType.Loki, - name: 'cortex without ruler api', - }, - { alerting: true } - ), - // not a supported datasource type - splunk: mockDataSource( - { - type: 'splunk', - name: 'splunk', - }, - { alerting: true } - ), - }; - - mocks.api.discoverFeatures.mockImplementation(async (dataSourceName) => { - if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { - return { - application: PromApplication.Cortex, - features: { - rulerApiEnabled: true, - alertManagerConfigApi: false, - federatedRules: false, - querySharding: false, - }, - }; - } - if (dataSourceName === 'loki with local rule store') { - return { - application: PromApplication.Cortex, - features: { - rulerApiEnabled: false, - alertManagerConfigApi: false, - federatedRules: false, - querySharding: false, - }, - }; - } - if (dataSourceName === 'cortex without ruler api') { - return { - application: PromApplication.Cortex, - features: { - rulerApiEnabled: false, - alertManagerConfigApi: false, - federatedRules: false, - querySharding: false, - }, - }; - } - - throw new Error(`${dataSourceName} not handled`); - }); - - mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => { - if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { - return null; - } - if (dataSourceName === 'loki with local rule store') { - throw { - status: 400, - data: { - message: 'GetRuleGroup unsupported in rule local store', - }, - }; - } - if (dataSourceName === 'cortex without ruler api') { - throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?'); - } - return null; - }); - - setDataSourceSrv(new MockDataSourceSrv(dataSources)); - mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); - mocks.searchFolders.mockResolvedValue([]); - - // render rule editor, select mimir/loki managed alerts - await renderRuleEditor(); - await waitFor(() => expect(mocks.api.discoverFeatures).toHaveBeenCalled()); - await waitFor(() => expect(mocks.searchFolders).toHaveBeenCalled()); - - await ui.inputs.name.find(); - await userEvent.click(await ui.buttons.lotexAlert.get()); - - // check that only rules sources that have ruler available are there - const dataSourceSelect = ui.inputs.dataSource.get(); - await userEvent.click(byRole('combobox').get(dataSourceSelect)); - expect(await byText('loki with ruler').query()).toBeInTheDocument(); - expect(byText('cortex with ruler').query()).toBeInTheDocument(); - expect(byText('loki with local rule store').query()).not.toBeInTheDocument(); - expect(byText('prom without ruler api').query()).not.toBeInTheDocument(); - expect(byText('splunk').query()).not.toBeInTheDocument(); - expect(byText('loki disabled for alerting').query()).not.toBeInTheDocument(); - }); -}); - -const clickSelectOption = async (selectElement: HTMLElement, optionText: Matcher): Promise => { - await userEvent.click(byRole('combobox').get(selectElement)); - await selectOptionInTest(selectElement, optionText as string); -}; diff --git a/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx new file mode 100644 index 00000000000..01c66b8cf27 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx @@ -0,0 +1,183 @@ +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { byRole, byText } from 'testing-library-selector'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { PromApiFeatures, PromApplication } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../manage-dashboards/state/actions'; + +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; +import { DataSourceType } from './utils/datasource'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +function getDiscoverFeaturesMock(application: PromApplication, features?: Partial) { + return { + application: application, + features: { + rulerApiEnabled: false, + alertManagerConfigApi: false, + federatedRules: false, + querySharding: false, + ...features, + }, + }; +} + +describe('RuleEditor cloud: checking editable data sources', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + + it('for cloud alerts, should only allow to select editable rules sources', async () => { + const dataSources = { + // can edit rules + loki: mockDataSource( + { + type: DataSourceType.Loki, + name: 'loki with ruler', + }, + { alerting: true } + ), + loki_disabled: mockDataSource( + { + type: DataSourceType.Loki, + name: 'loki disabled for alerting', + jsonData: { + manageAlerts: false, + }, + }, + { alerting: true } + ), + // can edit rules + prom: mockDataSource( + { + type: DataSourceType.Prometheus, + name: 'cortex with ruler', + }, + { alerting: true } + ), + // cannot edit rules + loki_local_rule_store: mockDataSource( + { + type: DataSourceType.Loki, + name: 'loki with local rule store', + }, + { alerting: true } + ), + // cannot edit rules + prom_no_ruler_api: mockDataSource( + { + type: DataSourceType.Loki, + name: 'cortex without ruler api', + }, + { alerting: true } + ), + // not a supported datasource type + splunk: mockDataSource( + { + type: 'splunk', + name: 'splunk', + }, + { alerting: true } + ), + }; + + mocks.api.discoverFeatures.mockImplementation(async (dataSourceName) => { + if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { + return getDiscoverFeaturesMock(PromApplication.Cortex, { rulerApiEnabled: true }); + } + if (dataSourceName === 'loki with local rule store') { + return getDiscoverFeaturesMock(PromApplication.Cortex); + } + if (dataSourceName === 'cortex without ruler api') { + return getDiscoverFeaturesMock(PromApplication.Cortex); + } + + throw new Error(`${dataSourceName} not handled`); + }); + + mocks.api.fetchRulerRulesGroup.mockImplementation(async ({ dataSourceName }) => { + if (dataSourceName === 'loki with ruler' || dataSourceName === 'cortex with ruler') { + return null; + } + if (dataSourceName === 'loki with local rule store') { + throw { + status: 400, + data: { + message: 'GetRuleGroup unsupported in rule local store', + }, + }; + } + if (dataSourceName === 'cortex without ruler api') { + throw new Error('404 from rules config endpoint. Perhaps ruler API is not enabled?'); + } + return null; + }); + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.searchFolders.mockResolvedValue([]); + + // render rule editor, select mimir/loki managed alerts + renderRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + await ui.inputs.name.find(); + await userEvent.click(await ui.buttons.lotexAlert.get()); + + // check that only rules sources that have ruler available are there + const dataSourceSelect = ui.inputs.dataSource.get(); + await userEvent.click(byRole('combobox').get(dataSourceSelect)); + expect(await byText('loki with ruler').query()).toBeInTheDocument(); + expect(byText('cortex with ruler').query()).toBeInTheDocument(); + expect(byText('loki with local rule store').query()).not.toBeInTheDocument(); + expect(byText('prom without ruler api').query()).not.toBeInTheDocument(); + expect(byText('splunk').query()).not.toBeInTheDocument(); + expect(byText('loki disabled for alerting').query()).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx new file mode 100644 index 00000000000..3443e9b1422 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx @@ -0,0 +1,153 @@ +import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; +import React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { byRole } from 'testing-library-selector'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { PromApplication } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../manage-dashboards/state/actions'; + +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); + +describe('RuleEditor cloud', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + + it('can create a new cloud alert', async () => { + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: true } + ), + }; + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + namespace1: [ + { + name: 'group1', + rules: [], + }, + ], + namespace2: [ + { + name: 'group2', + rules: [], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([]); + + mocks.api.discoverFeatures.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + + renderRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + await userEvent.click(await ui.buttons.lotexAlert.find()); + + const dataSourceSelect = ui.inputs.dataSource.get(); + await userEvent.click(byRole('combobox').get(dataSourceSelect)); + await clickSelectOption(dataSourceSelect, 'Prom (default)'); + await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled()); + + await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); + + await userEvent.type(ui.inputs.name.get(), 'my great new rule'); + await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); + await clickSelectOption(ui.inputs.group.get(), 'group2'); + + await userEvent.type(ui.inputs.annotationValue(0).get(), 'some summary'); + await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + + await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); + + // save and check what was sent to backend + await userEvent.click(ui.buttons.save.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: 'Prom', apiVersion: 'legacy' }, + 'namespace2', + { + name: 'group2', + rules: [ + { + alert: 'my great new rule', + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn' }, + expr: 'up == 1', + for: '1m', + }, + ], + } + ); + }); +}); diff --git a/public/app/features/alerting/unified/RuleEditorExisting.test.tsx b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx new file mode 100644 index 00000000000..1debcd414ab --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorExisting.test.tsx @@ -0,0 +1,219 @@ +import { render, waitFor, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Route, Router } from 'react-router-dom'; +import { ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOptionMatch } from 'test/helpers/selectOptionInTest'; +import { byRole } from 'testing-library-selector'; + +import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { ADD_NEW_FOLER_OPTION } from 'app/core/components/Select/FolderPicker'; +import { contextSrv } from 'app/core/services/context_srv'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { configureStore } from 'app/store/configureStore'; +import { GrafanaAlertStateDecision } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; +import { backendSrv } from '../../../core/services/backend_srv'; +import { AccessControlAction } from '../../../types'; + +import RuleEditor from './RuleEditor'; +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv, mockFolder } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { getDefaultQueries } from './utils/rule-form'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +function renderRuleEditor(identifier?: string) { + const store = configureStore(); + + locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`); + + return render( + + + + + + ); +} + +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); +describe('RuleEditor grafana managed rules', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + + it('can edit grafana managed rule', async () => { + const uid = 'FOOBAR123'; + const folder = { + title: 'Folder A', + uid: 'abcd', + id: 1, + }; + + const slashedFolder = { + title: 'Folder with /', + uid: 'abcde', + id: 2, + }; + + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: false } + ), + }; + + jest.spyOn(backendSrv, 'getFolderByUid').mockResolvedValue({ + ...mockFolder(), + accessControl: { + [AccessControlAction.AlertingRuleUpdate]: true, + }, + }); + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRules.mockResolvedValue({ + [folder.title]: [ + { + interval: '1m', + name: 'my great new rule', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary' }, + labels: { severity: 'warn', team: 'the a-team' }, + for: '5m', + grafana_alert: { + uid, + namespace_uid: 'abcd', + namespace_id: 1, + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: GrafanaAlertStateDecision.NoData, + title: 'my great new rule', + }, + }, + ], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([folder, slashedFolder] as DashboardSearchHit[]); + + renderRuleEditor(uid); + + // check that it's filled in + const nameInput = await ui.inputs.name.find(); + expect(nameInput).toHaveValue('my great new rule'); + //check that folder is in the list + expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); + expect(ui.inputs.annotationValue(0).get()).toHaveValue('some description'); + expect(ui.inputs.annotationValue(1).get()).toHaveValue('some summary'); + + //check that slashed folders are not in the list + expect(ui.inputs.folder.get()).toHaveTextContent(new RegExp(folder.title)); + expect(ui.inputs.folder.get()).not.toHaveTextContent(new RegExp(slashedFolder.title)); + + //check that slashes warning is only shown once user search slashes + //todo: move this test to a unit test in FolderAndGroup unit test + // const folderInput = await ui.inputs.folderContainer.find(); + // expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); + // await userEvent.type(within(folderInput).getByRole('combobox'), 'new slashed //'); + // expect(within(folderInput).getByText("Folders with '/' character are not allowed.")).toBeInTheDocument(); + // await userEvent.keyboard('{backspace} {backspace}{backspace}'); + // expect(within(folderInput).queryByText("Folders with '/' character are not allowed.")).not.toBeInTheDocument(); + + // add an annotation + await clickSelectOptionMatch(ui.inputs.annotationKey(2).get(), /Add new/); + await userEvent.type(byRole('textbox').get(ui.inputs.annotationKey(2).get()), 'custom'); + await userEvent.type(ui.inputs.annotationValue(2).get(), 'value'); + + //add a label + await userEvent.type(getLabelInput(ui.inputs.labelKey(2).get()), 'custom{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(2).get()), 'value{enter}'); + + // save and check what was sent to backend + await userEvent.click(ui.buttons.save.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + + //check that '+ Add new' option is in folders drop down even if we don't have values + const emptyFolderInput = await ui.inputs.folderContainer.find(); + mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]); + await renderRuleEditor(uid); + await userEvent.click(within(emptyFolderInput).getByRole('combobox')); + expect(screen.getByText(ADD_NEW_FOLER_OPTION)).toBeInTheDocument(); + + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + 'Folder A', + { + interval: '1m', + name: 'my great new rule', + rules: [ + { + annotations: { description: 'some description', summary: 'some summary', custom: 'value' }, + labels: { severity: 'warn', team: 'the a-team', custom: 'value' }, + for: '5m', + grafana_alert: { + uid, + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: 'NoData', + title: 'my great new rule', + }, + }, + ], + } + ); + }); +}); diff --git a/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx new file mode 100644 index 00000000000..83022dd54f8 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorGrafanaRules.test.tsx @@ -0,0 +1,169 @@ +import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; +import React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { byRole } from 'testing-library-selector'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { DashboardSearchHit } from 'app/features/search/types'; +import { GrafanaAlertStateDecision, PromApplication } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../../../app/features/manage-dashboards/state/actions'; + +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; +import { GRAFANA_RULES_SOURCE_NAME } from './utils/datasource'; +import { getDefaultQueries } from './utils/rule-form'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); +describe('RuleEditor grafana managed rules', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + + it('can create new grafana managed alert', async () => { + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: false } + ), + }; + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + 'Folder A': [ + { + name: 'group1', + rules: [], + }, + ], + namespace2: [ + { + name: 'group2', + rules: [], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([ + { + title: 'Folder A', + id: 1, + }, + { + title: 'Folder B', + id: 2, + }, + { + title: 'Folder / with slash', + id: 2, + }, + ] as DashboardSearchHit[]); + + mocks.api.discoverFeatures.mockResolvedValue({ + application: PromApplication.Prometheus, + features: { + rulerApiEnabled: false, + }, + }); + renderRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + + await userEvent.type(await ui.inputs.name.find(), 'my great new rule'); + + const folderInput = await ui.inputs.folder.find(); + await clickSelectOption(folderInput, 'Folder A'); + const groupInput = await ui.inputs.group.find(); + await userEvent.click(byRole('combobox').get(groupInput)); + await clickSelectOption(groupInput, 'group1 (1m)'); + await userEvent.type(ui.inputs.annotationValue(1).get(), 'some description'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + + await userEvent.type(getLabelInput(ui.inputs.labelKey(0).get()), 'severity{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(0).get()), 'warn{enter}'); + //8 segons + + // save and check what was sent to backend + await userEvent.click(ui.buttons.save.get()); + // 9seg + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + // 9seg + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, + 'Folder A', + { + interval: '1m', + name: 'group1', + rules: [ + { + annotations: { description: 'some description' }, + labels: { severity: 'warn' }, + for: '5m', + grafana_alert: { + condition: 'B', + data: getDefaultQueries(), + exec_err_state: GrafanaAlertStateDecision.Error, + no_data_state: 'NoData', + title: 'my great new rule', + }, + }, + ], + } + ); + }); +}); diff --git a/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx new file mode 100644 index 00000000000..73aa134df89 --- /dev/null +++ b/public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx @@ -0,0 +1,160 @@ +import { waitFor, screen, within, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; +import React from 'react'; +import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor'; +import { clickSelectOption } from 'test/helpers/selectOptionInTest'; +import { byRole, byText } from 'testing-library-selector'; + +import { setDataSourceSrv } from '@grafana/runtime'; +import { contextSrv } from 'app/core/services/context_srv'; +import { PromApplication } from 'app/types/unified-alerting-dto'; + +import { searchFolders } from '../../manage-dashboards/state/actions'; + +import { discoverFeatures } from './api/buildInfo'; +import { fetchRulerRules, fetchRulerRulesGroup, fetchRulerRulesNamespace, setRulerRuleGroup } from './api/ruler'; +import { ExpressionEditorProps } from './components/rule-editor/ExpressionEditor'; +import { disableRBAC, mockDataSource, MockDataSourceSrv } from './mocks'; +import { fetchRulerRulesIfNotFetchedYet } from './state/actions'; +import * as config from './utils/config'; + +jest.mock('./components/rule-editor/ExpressionEditor', () => ({ + // eslint-disable-next-line react/display-name + ExpressionEditor: ({ value, onChange }: ExpressionEditorProps) => ( + onChange(e.target.value)} /> + ), +})); + +jest.mock('./api/buildInfo'); +jest.mock('./api/ruler'); +jest.mock('../../../../app/features/manage-dashboards/state/actions'); + +// there's no angular scope in test and things go terribly wrong when trying to render the query editor row. +// lets just skip it +jest.mock('app/features/query/components/QueryEditorRow', () => ({ + // eslint-disable-next-line react/display-name + QueryEditorRow: () =>

hi

, +})); + +jest.spyOn(config, 'getAllDataSources'); + +const mocks = { + getAllDataSources: jest.mocked(config.getAllDataSources), + searchFolders: jest.mocked(searchFolders), + api: { + discoverFeatures: jest.mocked(discoverFeatures), + fetchRulerRulesGroup: jest.mocked(fetchRulerRulesGroup), + setRulerRuleGroup: jest.mocked(setRulerRuleGroup), + fetchRulerRulesNamespace: jest.mocked(fetchRulerRulesNamespace), + fetchRulerRules: jest.mocked(fetchRulerRules), + fetchRulerRulesIfNotFetchedYet: jest.mocked(fetchRulerRulesIfNotFetchedYet), + }, +}; + +const getLabelInput = (selector: HTMLElement) => within(selector).getByRole('combobox'); + +describe('RuleEditor recording rules', () => { + beforeEach(() => { + jest.clearAllMocks(); + contextSrv.isEditor = true; + contextSrv.hasEditPermissionInFolders = true; + }); + + disableRBAC(); + it('can create a new cloud recording rule', async () => { + const dataSources = { + default: mockDataSource( + { + type: 'prometheus', + name: 'Prom', + isDefault: true, + }, + { alerting: true } + ), + }; + + setDataSourceSrv(new MockDataSourceSrv(dataSources)); + mocks.getAllDataSources.mockReturnValue(Object.values(dataSources)); + mocks.api.setRulerRuleGroup.mockResolvedValue(); + mocks.api.fetchRulerRulesNamespace.mockResolvedValue([]); + mocks.api.fetchRulerRulesGroup.mockResolvedValue({ + name: 'group2', + rules: [], + }); + mocks.api.fetchRulerRules.mockResolvedValue({ + namespace1: [ + { + name: 'group1', + rules: [], + }, + ], + namespace2: [ + { + name: 'group2', + rules: [], + }, + ], + }); + mocks.searchFolders.mockResolvedValue([]); + + mocks.api.discoverFeatures.mockResolvedValue({ + application: PromApplication.Cortex, + features: { + rulerApiEnabled: true, + }, + }); + + renderRuleEditor(); + await waitForElementToBeRemoved(screen.getAllByTestId('Spinner')); + await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule'); + await userEvent.click(await ui.buttons.lotexRecordingRule.get()); + + const dataSourceSelect = ui.inputs.dataSource.get(); + await userEvent.click(byRole('combobox').get(dataSourceSelect)); + + await clickSelectOption(dataSourceSelect, 'Prom (default)'); + await clickSelectOption(ui.inputs.namespace.get(), 'namespace2'); + await clickSelectOption(ui.inputs.group.get(), 'group2'); + + await userEvent.type(await ui.inputs.expr.find(), 'up == 1'); + + // TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed + await userEvent.click(ui.buttons.addLabel.get(), { pointerEventsCheck: PointerEventsCheckLevel.Never }); + + await userEvent.type(getLabelInput(ui.inputs.labelKey(1).get()), 'team{enter}'); + await userEvent.type(getLabelInput(ui.inputs.labelValue(1).get()), 'the a-team{enter}'); + + // try to save, find out that recording rule name is invalid + await userEvent.click(ui.buttons.save.get()); + await waitFor(() => + expect( + byText( + 'Recording rule name must be valid metric name. It may only contain letters, numbers, and colons. It may not contain whitespace.' + ).get() + ).toBeInTheDocument() + ); + expect(mocks.api.setRulerRuleGroup).not.toBeCalled(); + + // fix name and re-submit + await userEvent.clear(await ui.inputs.name.find()); + await userEvent.type(await ui.inputs.name.find(), 'my:great:new:recording:rule'); + + // save and check what was sent to backend + await userEvent.click(ui.buttons.save.get()); + await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( + { dataSourceName: 'Prom', apiVersion: 'legacy' }, + 'namespace2', + { + name: 'group2', + rules: [ + { + record: 'my:great:new:recording:rule', + labels: { team: 'the a-team' }, + expr: 'up == 1', + }, + ], + } + ); + }); +}); diff --git a/public/test/helpers/alertingRuleEditor.tsx b/public/test/helpers/alertingRuleEditor.tsx new file mode 100644 index 00000000000..eac7a02fc57 --- /dev/null +++ b/public/test/helpers/alertingRuleEditor.tsx @@ -0,0 +1,50 @@ +import { render } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Router, Route } from 'react-router-dom'; +import { byRole, byTestId } from 'testing-library-selector'; + +import { selectors } from '@grafana/e2e-selectors'; +import { locationService } from '@grafana/runtime'; +import RuleEditor from 'app/features/alerting/unified/RuleEditor'; +import { configureStore } from 'app/store/configureStore'; + +export const ui = { + inputs: { + name: byRole('textbox', { name: /rule name name for the alert rule\./i }), + alertType: byTestId('alert-type-picker'), + dataSource: byTestId('datasource-picker'), + folder: byTestId('folder-picker'), + folderContainer: byTestId(selectors.components.FolderPicker.containerV2), + namespace: byTestId('namespace-picker'), + group: byTestId('group-picker'), + annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`), + annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), + labelKey: (idx: number) => byTestId(`label-key-${idx}`), + labelValue: (idx: number) => byTestId(`label-value-${idx}`), + expr: byTestId('expr'), + }, + buttons: { + save: byRole('button', { name: 'Save' }), + addAnnotation: byRole('button', { name: /Add info/ }), + addLabel: byRole('button', { name: /Add label/ }), + // alert type buttons + grafanaManagedAlert: byRole('button', { name: /Grafana managed/ }), + lotexAlert: byRole('button', { name: /Mimir or Loki alert/ }), + lotexRecordingRule: byRole('button', { name: /Mimir or Loki recording rule/ }), + }, +}; + +export function renderRuleEditor(identifier?: string) { + const store = configureStore(); + + locationService.push(identifier ? `/alerting/${identifier}/edit` : `/alerting/new`); + + return render( + + + + + + ); +} diff --git a/public/test/helpers/selectOptionInTest.ts b/public/test/helpers/selectOptionInTest.ts index 2e3ec5c3e73..c1ff432dacc 100644 --- a/public/test/helpers/selectOptionInTest.ts +++ b/public/test/helpers/selectOptionInTest.ts @@ -1,5 +1,7 @@ -import { waitFor } from '@testing-library/react'; +import { Matcher, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { select } from 'react-select-event'; +import { byRole } from 'testing-library-selector'; // Used to select an option or options from a Select in unit tests export const selectOptionInTest = async ( @@ -10,3 +12,12 @@ export const selectOptionInTest = async ( // Finds the parent of the Select so you can assert if it has a value export const getSelectParent = (input: HTMLElement) => input.parentElement?.parentElement?.parentElement?.parentElement?.parentElement; + +export const clickSelectOption = async (selectElement: HTMLElement, optionText: string): Promise => { + await userEvent.click(byRole('combobox').get(selectElement)); + await selectOptionInTest(selectElement, optionText); +}; +export const clickSelectOptionMatch = async (selectElement: HTMLElement, optionText: Matcher): Promise => { + await userEvent.click(byRole('combobox').get(selectElement)); + await selectOptionInTest(selectElement, optionText as string); +};