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);
+};