From 777d2e8e4aaf6f1d5e759e9d856fa6fb70587ec0 Mon Sep 17 00:00:00 2001 From: Gilles De Mey Date: Wed, 21 May 2025 12:47:31 +0200 Subject: [PATCH] Alerting: mocking and testing of Alerting package (#105342) --- .github/CODEOWNERS | 1 + packages/grafana-alerting/package.json | 8 +- packages/grafana-alerting/scripts/codegen.ts | 3 +- .../src/grafana/api/v0alpha1/api.gen.ts | 189 +++++++++++++----- .../src/grafana/api/v0alpha1/api.ts | 7 +- .../src/grafana/api/v0alpha1/const.ts | 2 + .../api/v0alpha1/mocks/fakes/Receivers.ts | 74 +++++++ .../api/v0alpha1/mocks/fakes/common.ts | 10 + .../ReceiverHandlers/createReceiverHandler.ts | 17 ++ .../ReceiverHandlers/deleteReceiverHandler.ts | 17 ++ .../deletecollectionReceiverHandler.ts | 17 ++ .../ReceiverHandlers/getReceiverHandler.ts | 17 ++ .../mocks/handlers/ReceiverHandlers/index.ts | 7 + .../ReceiverHandlers/listReceiverHandler.ts | 17 ++ .../replaceReceiverHandler.ts | 17 ++ .../ReceiverHandlers/updateReceiverHandler.ts | 17 ++ .../api/v0alpha1/mocks/handlers/index.ts | 7 + .../src/grafana/api/v0alpha1/types.ts | 21 +- .../ContactPointSelector.test.scenario.tsx | 34 ++++ .../ContactPointSelector.test.tsx | 54 +++++ .../ContactPointSelector.tsx | 6 +- .../contactPoints/hooks/useContactPoints.tsx | 2 +- .../src/grafana/contactPoints/utils.test.ts | 49 +++++ .../src/grafana/mocks/util.ts | 15 ++ packages/grafana-alerting/src/unstable.ts | 8 +- .../grafana-alerting/tests/test-utils.tsx | 52 +++++ packages/grafana-alerting/tsconfig.json | 11 +- .../grafana-test-utils/src/server/index.ts | 2 + yarn.lock | 81 ++++---- 29 files changed, 657 insertions(+), 105 deletions(-) create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/const.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/Receivers.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/common.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/index.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts create mode 100644 packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/index.ts create mode 100644 packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.tsx create mode 100644 packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.tsx rename packages/grafana-alerting/src/grafana/contactPoints/components/{ => ContactPointSelector}/ContactPointSelector.tsx (88%) create mode 100644 packages/grafana-alerting/src/grafana/contactPoints/utils.test.ts create mode 100644 packages/grafana-alerting/src/grafana/mocks/util.ts create mode 100644 packages/grafana-alerting/tests/test-utils.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index bd621ad0bc1..5ebbc17a7ce 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -440,6 +440,7 @@ /packages/grafana-ui/src/utils/storybook/ @grafana/grafana-frontend-platform /packages/grafana-alerting/ @grafana/alerting-frontend /packages/grafana-i18n/ @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend +/packages/grafana-test-utils @grafana/grafana-frontend-platform # root files, mostly frontend /.browserslistrc @grafana/frontend-ops diff --git a/packages/grafana-alerting/package.json b/packages/grafana-alerting/package.json index 1b454a3277b..31980da5f3a 100644 --- a/packages/grafana-alerting/package.json +++ b/packages/grafana-alerting/package.json @@ -40,24 +40,30 @@ "codegen": "rtk-query-codegen-openapi ./scripts/codegen.ts" }, "devDependencies": { + "@faker-js/faker": "^9.7.0", + "@grafana/test-utils": "workspace:*", "@grafana/tsconfig": "^2.0.0", "@rtk-query/codegen-openapi": "^2.0.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/lodash": "^4", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", "react": "18.3.1", "react-dom": "18.3.1", + "react-redux": "^9.2.0", "type-fest": "^4.40.0", "typescript": "5.7.3" }, "peerDependencies": { "@grafana/runtime": "^12.0.0-pre", "@grafana/ui": "^12.0.0-pre", + "@reduxjs/toolkit": "^2.8.0", "react": "^18.0.0", "react-dom": "^18.0.0" }, "dependencies": { - "@reduxjs/toolkit": "^2.8.0", "lodash": "^4.17.21" } } diff --git a/packages/grafana-alerting/scripts/codegen.ts b/packages/grafana-alerting/scripts/codegen.ts index 4eec25b2564..53ec8976a85 100644 --- a/packages/grafana-alerting/scripts/codegen.ts +++ b/packages/grafana-alerting/scripts/codegen.ts @@ -9,6 +9,7 @@ import type { ConfigFile } from '@rtk-query/codegen-openapi'; // ℹ️ append versions here to generate additional API clients const VERSIONS = ['v0alpha1'] as const; +const GROUP = 'notifications.alerting.grafana.app' as const; type OutputFile = Omit; type OutputFiles = Record; @@ -19,7 +20,7 @@ const outputFiles = VERSIONS.reduce((acc, version) => { // ℹ️ these snapshots are generated by running "go test pkg/tests/apis/openapi_test.go" and "scripts/process-specs.ts", // see the README in the "openapi_snapshots" directory - const schemaFile = `../../../data/openapi/notifications.alerting.grafana.app-${version}.json`; + const schemaFile = `../../../data/openapi/${GROUP}-${version}.json`; // ℹ️ make sure there is a API file in each versioned directory const apiFile = `../src/grafana/api/${version}/api.ts`; diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/api.gen.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/api.gen.ts index b1370c9b7fb..6166032bb5d 100644 --- a/packages/grafana-alerting/src/grafana/api/v0alpha1/api.gen.ts +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/api.gen.ts @@ -1239,7 +1239,7 @@ export type ObjectMeta = { Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ uid?: string; }; -export type Integration = { +export type ReceiverIntegration = { disableResolveMessage?: boolean; secureFields?: { [key: string]: boolean; @@ -1251,9 +1251,59 @@ export type Integration = { uid?: string; }; export type ReceiverSpec = { - integrations: Integration[]; + integrations: ReceiverIntegration[]; title: string; }; +export type ReceiverstatusOperatorState = { + /** descriptiveState is an optional more descriptive state field which has no requirements on format */ + descriptiveState?: string; + /** details contains any extra information that is operator-specific */ + details?: { + [key: string]: object; + }; + /** lastEvaluation is the ResourceVersion last evaluated */ + lastEvaluation: string; + /** state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation. */ + state: string; +}; +export type ReceiverStatus = { + /** additionalFields is reserved for future use */ + additionalFields?: { + [key: string]: object; + }; + /** operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ + operatorStates?: { + [key: string]: ReceiverstatusOperatorState; + }; +}; +export type Receiver = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ObjectMeta; + /** Spec is the spec of the Receiver */ + spec: ReceiverSpec; + status: ReceiverStatus; +}; +export type ListMeta = { + /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ + continue?: string; + /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ + remainingItemCount?: number; + /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; +}; +export type ReceiverList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: Receiver[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ListMeta; +}; export type StatusCause = { /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. @@ -1280,16 +1330,6 @@ export type StatusDetails = { /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ uid?: string; }; -export type ListMeta = { - /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ - continue?: string; - /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ - remainingItemCount?: number; - /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ - resourceVersion?: string; - /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ - selfLink?: string; -}; export type Status = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion?: string; @@ -1308,51 +1348,56 @@ export type Status = { /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ status?: string; }; -export type Receiver = { - /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ - apiVersion?: string; - /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - metadata: ObjectMeta; - /** Spec is the spec of the Receiver */ - spec: ReceiverSpec; - status: Status; -}; -export type ReceiverList = { - /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ - apiVersion?: string; - items: Receiver[]; - /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - metadata: ListMeta; -}; export type Patch = object; -export type RouteDefaults = { +export type RoutingTreeRouteDefaults = { group_by?: string[]; group_interval?: string; group_wait?: string; receiver: string; repeat_interval?: string; }; -export type Matcher = { +export type RoutingTreeMatcher = { label: string; type: string; value: string; }; -export type Route = { +export type RoutingTreeRoute = { + active_time_intervals?: string[]; continue: boolean; group_by?: string[]; group_interval?: string; group_wait?: string; - matchers?: Matcher[]; + matchers?: RoutingTreeMatcher[]; mute_time_intervals?: string[]; receiver?: string; repeat_interval?: string; - routes?: Route[]; + routes?: RoutingTreeRoute[]; }; -export type RoutingtreeSpec = { - defaults: RouteDefaults; - routes: Route[]; +export type RoutingTreeSpec = { + defaults: RoutingTreeRouteDefaults; + routes: RoutingTreeRoute[]; +}; +export type RoutingTreestatusOperatorState = { + /** descriptiveState is an optional more descriptive state field which has no requirements on format */ + descriptiveState?: string; + /** details contains any extra information that is operator-specific */ + details?: { + [key: string]: object; + }; + /** lastEvaluation is the ResourceVersion last evaluated */ + lastEvaluation: string; + /** state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation. */ + state: string; +}; +export type RoutingTreeStatus = { + /** additionalFields is reserved for future use */ + additionalFields?: { + [key: string]: object; + }; + /** operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ + operatorStates?: { + [key: string]: RoutingTreestatusOperatorState; + }; }; export type RoutingTree = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1361,8 +1406,8 @@ export type RoutingTree = { kind?: string; metadata: ObjectMeta; /** Spec is the spec of the RoutingTree */ - spec: RoutingtreeSpec; - status: Status; + spec: RoutingTreeSpec; + status: RoutingTreeStatus; }; export type RoutingTreeList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1372,10 +1417,32 @@ export type RoutingTreeList = { kind?: string; metadata: ListMeta; }; -export type TemplategroupSpec = { +export type TemplateGroupSpec = { content: string; title: string; }; +export type TemplateGroupstatusOperatorState = { + /** descriptiveState is an optional more descriptive state field which has no requirements on format */ + descriptiveState?: string; + /** details contains any extra information that is operator-specific */ + details?: { + [key: string]: object; + }; + /** lastEvaluation is the ResourceVersion last evaluated */ + lastEvaluation: string; + /** state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation. */ + state: string; +}; +export type TemplateGroupStatus = { + /** additionalFields is reserved for future use */ + additionalFields?: { + [key: string]: object; + }; + /** operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ + operatorStates?: { + [key: string]: TemplateGroupstatusOperatorState; + }; +}; export type TemplateGroup = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ apiVersion?: string; @@ -1383,8 +1450,8 @@ export type TemplateGroup = { kind?: string; metadata: ObjectMeta; /** Spec is the spec of the TemplateGroup */ - spec: TemplategroupSpec; - status: Status; + spec: TemplateGroupSpec; + status: TemplateGroupStatus; }; export type TemplateGroupList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1394,21 +1461,43 @@ export type TemplateGroupList = { kind?: string; metadata: ListMeta; }; -export type TimeRange = { +export type TimeIntervalTimeRange = { end_time: string; start_time: string; }; -export type Interval = { +export type TimeIntervalInterval = { days_of_month?: string[]; location?: string; months?: string[]; - times?: TimeRange[]; + times?: TimeIntervalTimeRange[]; weekdays?: string[]; years?: string[]; }; -export type TimeintervalSpec = { +export type TimeIntervalSpec = { name: string; - time_intervals: Interval[]; + time_intervals: TimeIntervalInterval[]; +}; +export type TimeIntervalstatusOperatorState = { + /** descriptiveState is an optional more descriptive state field which has no requirements on format */ + descriptiveState?: string; + /** details contains any extra information that is operator-specific */ + details?: { + [key: string]: object; + }; + /** lastEvaluation is the ResourceVersion last evaluated */ + lastEvaluation: string; + /** state describes the state of the lastEvaluation. It is limited to three possible states for machine evaluation. */ + state: string; +}; +export type TimeIntervalStatus = { + /** additionalFields is reserved for future use */ + additionalFields?: { + [key: string]: object; + }; + /** operatorStates is a map of operator ID to operator state evaluations. Any operator which consumes this kind SHOULD add its state evaluation information to this field. */ + operatorStates?: { + [key: string]: TimeIntervalstatusOperatorState; + }; }; export type TimeInterval = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ @@ -1417,8 +1506,8 @@ export type TimeInterval = { kind?: string; metadata: ObjectMeta; /** Spec is the spec of the TimeInterval */ - spec: TimeintervalSpec; - status: Status; + spec: TimeIntervalSpec; + status: TimeIntervalStatus; }; export type TimeIntervalList = { /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/api.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/api.ts index 32983acc230..6d22d1df2c2 100644 --- a/packages/grafana-alerting/src/grafana/api/v0alpha1/api.ts +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/api.ts @@ -2,8 +2,7 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { getAPIBaseURL, getAPIReducerPath } from '../util'; -const VERSION = 'v0alpha1'; -const GROUP = 'notifications.alerting.grafana.app'; +import { GROUP, VERSION } from './const'; const baseUrl = getAPIBaseURL(GROUP, VERSION); const reducerPath = getAPIReducerPath(GROUP, VERSION); @@ -11,7 +10,9 @@ const reducerPath = getAPIReducerPath(GROUP, VERSION); export const api = createApi({ reducerPath, baseQuery: fetchBaseQuery({ - baseUrl, + // Set URL correctly so MSW can intercept requests + // https://mswjs.io/docs/runbook#rtk-query-requests-are-not-intercepted + baseUrl: new URL(baseUrl, globalThis.location.origin).href, }), endpoints: () => ({}), }); diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/const.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/const.ts new file mode 100644 index 00000000000..a196bb9516e --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/const.ts @@ -0,0 +1,2 @@ +export const VERSION = 'v0alpha1' as const; +export const GROUP = 'notifications.alerting.grafana.app' as const; diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/Receivers.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/Receivers.ts new file mode 100644 index 00000000000..f2f5563d25f --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/Receivers.ts @@ -0,0 +1,74 @@ +import { faker } from '@faker-js/faker'; +import { Factory } from 'fishery'; + +import { DEFAULT_NAMESPACE, generateResourceVersion, generateTitle, generateUID } from '../../../../mocks/util'; +import { GROUP, VERSION } from '../../const'; +import { + ContactPoint, + ContactPointMetadataAnnotations, + EnhancedListReceiverApiResponse, + Integration, +} from '../../types'; + +import { AlertingEntityMetadataAnnotationsFactory } from './common'; + +export const ListReceiverApiResponseFactory = Factory.define(() => ({ + kind: 'ReceiverList', + apiVersion: `${GROUP}/${VERSION}`, + metadata: {}, + items: ContactPointFactory.buildList(5), +})); + +export const ContactPointFactory = Factory.define(() => { + const title = generateTitle(); + + return { + metadata: { + name: btoa(title), + namespace: DEFAULT_NAMESPACE, + uid: generateUID(), + resourceVersion: generateResourceVersion(), + annotations: ContactPointMetadataAnnotationsFactory.build(), + }, + spec: ContactPointSpecFactory.build({ title }), + status: {}, + } satisfies ContactPoint; +}); + +export const ContactPointSpecFactory = Factory.define(() => ({ + title: generateTitle(), + // use two unique random integrations by default + integrations: faker.helpers.uniqueArray(IntegrationUnion, 2).map((integration) => integration.build()), +})); + +export const GenericIntegrationFactory = Factory.define(() => ({ + type: 'generic', + disableResolveMessage: false, + settings: { + foo: 'bar', + }, +})); + +export const EmailIntegrationFactory = Factory.define(() => ({ + type: 'email', + settings: { + addresses: faker.internet.email(), + }, +})); + +export const SlackIntegrationFactory = Factory.define(() => ({ + type: 'slack', + settings: { + mentionChannel: '#alerts', + }, +})); + +const IntegrationUnion = [EmailIntegrationFactory, SlackIntegrationFactory]; + +// by default the contact points will be in use by a route and a rule +export const ContactPointMetadataAnnotationsFactory = Factory.define(() => ({ + 'grafana.com/access/canReadSecrets': 'true', + 'grafana.com/inUse/routes': '1', + 'grafana.com/inUse/rules': '1', + ...AlertingEntityMetadataAnnotationsFactory.build(), +})); diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/common.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/common.ts new file mode 100644 index 00000000000..c57fd23cd24 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/fakes/common.ts @@ -0,0 +1,10 @@ +import { Factory } from 'fishery'; + +import { AlertingEntityMetadataAnnotations } from '../../types'; + +export const AlertingEntityMetadataAnnotationsFactory = Factory.define(() => ({ + 'grafana.com/access/canAdmin': 'true', + 'grafana.com/access/canDelete': 'true', + 'grafana.com/access/canWrite': 'true', + 'grafana.com/provenance': '', +})); diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts new file mode 100644 index 00000000000..6ca0ba803c4 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/createReceiverHandler.ts @@ -0,0 +1,17 @@ +import { HttpResponse, http } from 'msw'; + +import { getAPIBaseURLForMocks } from '../../../../../mocks/util'; +import { CreateReceiverApiResponse } from '../../../api.gen'; +import { GROUP, VERSION } from '../../../const'; + +export function createReceiverHandler( + data: CreateReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) +) { + return http.post(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + if (typeof data === 'function') { + return data(info); + } + + return HttpResponse.json(data); + }); +} diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts new file mode 100644 index 00000000000..13d47dda277 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/deleteReceiverHandler.ts @@ -0,0 +1,17 @@ +import { HttpResponse, http } from 'msw'; + +import { getAPIBaseURLForMocks } from '../../../../../mocks/util'; +import { DeleteReceiverApiResponse } from '../../../api.gen'; +import { GROUP, VERSION } from '../../../const'; + +export function deleteReceiverHandler( + data: DeleteReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) +) { + return http.delete(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + if (typeof data === 'function') { + return data(info); + } + + return HttpResponse.json(data); + }); +} diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts new file mode 100644 index 00000000000..8d1141df76f --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/deletecollectionReceiverHandler.ts @@ -0,0 +1,17 @@ +import { HttpResponse, http } from 'msw'; + +import { getAPIBaseURLForMocks } from '../../../../../mocks/util'; +import { DeletecollectionReceiverApiResponse } from '../../../api.gen'; +import { GROUP, VERSION } from '../../../const'; + +export function deletecollectionReceiverHandler( + data: DeletecollectionReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) +) { + return http.delete(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + if (typeof data === 'function') { + return data(info); + } + + return HttpResponse.json(data); + }); +} diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts new file mode 100644 index 00000000000..bc82c667dd0 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/getReceiverHandler.ts @@ -0,0 +1,17 @@ +import { HttpResponse, http } from 'msw'; + +import { getAPIBaseURLForMocks } from '../../../../../mocks/util'; +import { GetReceiverApiResponse } from '../../../api.gen'; +import { GROUP, VERSION } from '../../../const'; + +export function getReceiverHandler( + data: GetReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) +) { + return http.get(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + if (typeof data === 'function') { + return data(info); + } + + return HttpResponse.json(data); + }); +} diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/index.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/index.ts new file mode 100644 index 00000000000..32dd8f7dd22 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/index.ts @@ -0,0 +1,7 @@ +export { createReceiverHandler } from './createReceiverHandler'; +export { deletecollectionReceiverHandler } from './deletecollectionReceiverHandler'; +export { deleteReceiverHandler } from './deleteReceiverHandler'; +export { getReceiverHandler } from './getReceiverHandler'; +export { listReceiverHandler } from './listReceiverHandler'; +export { replaceReceiverHandler } from './replaceReceiverHandler'; +export { updateReceiverHandler } from './updateReceiverHandler'; diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts new file mode 100644 index 00000000000..6040ffe4d8a --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/listReceiverHandler.ts @@ -0,0 +1,17 @@ +import { HttpResponse, http } from 'msw'; + +import { getAPIBaseURLForMocks } from '../../../../../mocks/util'; +import { GROUP, VERSION } from '../../../const'; +import { EnhancedListReceiverApiResponse } from '../../../types'; + +export function listReceiverHandler( + data: EnhancedListReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) +) { + return http.get(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) { + if (typeof data === 'function') { + return data(info); + } + + return HttpResponse.json(data); + }); +} diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts new file mode 100644 index 00000000000..85ab132925e --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/replaceReceiverHandler.ts @@ -0,0 +1,17 @@ +import { HttpResponse, http } from 'msw'; + +import { getAPIBaseURLForMocks } from '../../../../../mocks/util'; +import { ReplaceReceiverApiResponse } from '../../../api.gen'; +import { GROUP, VERSION } from '../../../const'; + +export function replaceReceiverHandler( + data: ReplaceReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) +) { + return http.put(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + if (typeof data === 'function') { + return data(info); + } + + return HttpResponse.json(data); + }); +} diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts new file mode 100644 index 00000000000..645a8774364 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/ReceiverHandlers/updateReceiverHandler.ts @@ -0,0 +1,17 @@ +import { HttpResponse, http } from 'msw'; + +import { getAPIBaseURLForMocks } from '../../../../../mocks/util'; +import { UpdateReceiverApiResponse } from '../../../api.gen'; +import { GROUP, VERSION } from '../../../const'; + +export function updateReceiverHandler( + data: UpdateReceiverApiResponse | ((info: Parameters[1]>[0]) => Response) +) { + return http.patch(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers/:name'), function handler(info) { + if (typeof data === 'function') { + return data(info); + } + + return HttpResponse.json(data); + }); +} diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/index.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/index.ts new file mode 100644 index 00000000000..d4f1313fb0f --- /dev/null +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/mocks/handlers/index.ts @@ -0,0 +1,7 @@ +export { createReceiverHandler } from './ReceiverHandlers/createReceiverHandler'; +export { deletecollectionReceiverHandler } from './ReceiverHandlers/deletecollectionReceiverHandler'; +export { deleteReceiverHandler } from './ReceiverHandlers/deleteReceiverHandler'; +export { getReceiverHandler } from './ReceiverHandlers/getReceiverHandler'; +export { listReceiverHandler } from './ReceiverHandlers/listReceiverHandler'; +export { replaceReceiverHandler } from './ReceiverHandlers/replaceReceiverHandler'; +export { updateReceiverHandler } from './ReceiverHandlers/updateReceiverHandler'; diff --git a/packages/grafana-alerting/src/grafana/api/v0alpha1/types.ts b/packages/grafana-alerting/src/grafana/api/v0alpha1/types.ts index c69f9312fec..cc4effa3d3e 100644 --- a/packages/grafana-alerting/src/grafana/api/v0alpha1/types.ts +++ b/packages/grafana-alerting/src/grafana/api/v0alpha1/types.ts @@ -3,7 +3,7 @@ */ import { MergeDeep, MergeExclusive, OverrideProperties } from 'type-fest'; -import type { ListReceiverApiResponse, Receiver, Integration as ReceiverIntegration } from './api.gen'; +import type { ListReceiverApiResponse, Receiver, ReceiverIntegration } from './api.gen'; type GenericIntegration = OverrideProperties< ReceiverIntegration, @@ -58,12 +58,31 @@ export type Integration = EmailIntegration | SlackIntegration | GenericIntegrati export type ContactPoint = MergeDeep< Receiver, { + metadata: { + annotations: ContactPointMetadataAnnotations; + }; spec: { integrations: Integration[]; }; } >; +export type ContactPointMetadataAnnotations = AlertingEntityMetadataAnnotations & + Partial<{ + // reading secrets is unique to contact points / receivers + 'grafana.com/access/canReadSecrets': 'true' | 'false'; + 'grafana.com/inUse/routes': `${number}`; + 'grafana.com/inUse/rules': `${number}`; + }>; + +export type AlertingEntityMetadataAnnotations = Partial<{ + 'grafana.com/access/canAdmin': 'true' | 'false'; + 'grafana.com/access/canDelete': 'true' | 'false'; + 'grafana.com/access/canWrite': 'true' | 'false'; + // used for provisioning to identify what system created the entity + 'grafana.com/provenance': string; +}>; + export type EnhancedListReceiverApiResponse = OverrideProperties< ListReceiverApiResponse, { diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.tsx new file mode 100644 index 00000000000..b8fb0803908 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.scenario.tsx @@ -0,0 +1,34 @@ +import { + ContactPointFactory, + EmailIntegrationFactory, + ListReceiverApiResponseFactory, + SlackIntegrationFactory, +} from '../../../api/v0alpha1/mocks/fakes/Receivers'; +import { listReceiverHandler } from '../../../api/v0alpha1/mocks/handlers/ReceiverHandlers'; + +export const simpleContactPointsList = ListReceiverApiResponseFactory.build({ + items: [ + // contact point with for testing with multiple different integrations – should show "email, slack" description + ContactPointFactory.build({ + spec: { + integrations: [EmailIntegrationFactory.build(), SlackIntegrationFactory.build()], + }, + }), + // contact point for testing "email (2)" description + ContactPointFactory.build({ + spec: { + integrations: EmailIntegrationFactory.buildList(2), + }, + }), + // contact point for testing "empty contact point" description + ContactPointFactory.build({ + spec: { integrations: [] }, + }), + ], +}); + +// export the simple contact points list as a separate list of handlers (scenario) so we can load it in the front-end +export const simpleContactPointsListScenario = [listReceiverHandler(simpleContactPointsList)]; + +// the default export will allow us to load this scenario on the front-end using the MSW web worker +export default simpleContactPointsListScenario; diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.tsx new file mode 100644 index 00000000000..7da4dba2471 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.test.tsx @@ -0,0 +1,54 @@ +import { setupMockServer } from '@grafana/test-utils/server'; + +import { render, screen, within } from '../../../../../tests/test-utils'; +import { getContactPointDescription } from '../../utils'; + +import { ContactPointSelector } from './ContactPointSelector'; +import { simpleContactPointsList, simpleContactPointsListScenario } from './ContactPointSelector.test.scenario'; + +const server = setupMockServer(); + +beforeEach(() => { + server.use(...simpleContactPointsListScenario); +}); + +beforeAll(() => { + // @TODO remove or move this, required for testing combobox 😮‍💨 + const mockGetBoundingClientRect = jest.fn(() => ({ + width: 120, + height: 120, + top: 0, + left: 0, + bottom: 0, + right: 0, + })); + Object.defineProperty(Element.prototype, 'getBoundingClientRect', { + value: mockGetBoundingClientRect, + }); +}); + +describe('listing contact points', () => { + it('should show a sorted list of contact points', async () => { + const onChangeHandler = jest.fn(); + + // render the contact point selector + const { user } = render(); + await user.click(screen.getByRole('combobox')); + + // make sure all options are rendered + expect(await screen.findAllByRole('option')).toHaveLength(simpleContactPointsList.items.length); + + for (const item of simpleContactPointsList.items) { + const option = await screen.findByRole('option', { name: new RegExp(item.spec.title) }); + expect(option).toBeInTheDocument(); + expect(within(option).getByText(getContactPointDescription(item))).toBeInTheDocument(); + } + + // test interaction with combobox and handler contract + const firstContactPoint = simpleContactPointsList.items[0]; + + const firstOption = await screen.findByText(firstContactPoint.spec.title); + await user.click(firstOption); + expect(onChangeHandler).toHaveBeenCalledWith(firstContactPoint); + }); +}); diff --git a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector.tsx b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx similarity index 88% rename from packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector.tsx rename to packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx index 91983b84434..07265fb36e9 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector.tsx +++ b/packages/grafana-alerting/src/grafana/contactPoints/components/ContactPointSelector/ContactPointSelector.tsx @@ -2,9 +2,9 @@ import { chain } from 'lodash'; import { Combobox, ComboboxOption } from '@grafana/ui'; -import { ContactPoint } from '../../api/v0alpha1/types'; -import { useListContactPointsv0alpha1 } from '../hooks/useContactPoints'; -import { getContactPointDescription } from '../utils'; +import type { ContactPoint } from '../../../api/v0alpha1/types'; +import { useListContactPointsv0alpha1 } from '../../hooks/useContactPoints'; +import { getContactPointDescription } from '../../utils'; const collator = new Intl.Collator('en', { sensitivity: 'accent' }); diff --git a/packages/grafana-alerting/src/grafana/contactPoints/hooks/useContactPoints.tsx b/packages/grafana-alerting/src/grafana/contactPoints/hooks/useContactPoints.tsx index 43aa3d7bbe4..ab0169f2891 100644 --- a/packages/grafana-alerting/src/grafana/contactPoints/hooks/useContactPoints.tsx +++ b/packages/grafana-alerting/src/grafana/contactPoints/hooks/useContactPoints.tsx @@ -1,4 +1,4 @@ -import { TypedUseQueryHookResult, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { type TypedUseQueryHookResult, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { type ListReceiverApiArg, alertingAPI } from '../../api/v0alpha1/api.gen'; import type { EnhancedListReceiverApiResponse } from '../../api/v0alpha1/types'; diff --git a/packages/grafana-alerting/src/grafana/contactPoints/utils.test.ts b/packages/grafana-alerting/src/grafana/contactPoints/utils.test.ts new file mode 100644 index 00000000000..1aa816c1ab1 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/contactPoints/utils.test.ts @@ -0,0 +1,49 @@ +import { + ContactPointFactory, + EmailIntegrationFactory, + GenericIntegrationFactory, + SlackIntegrationFactory, +} from '../api/v0alpha1/mocks/fakes/Receivers'; + +import { getContactPointDescription } from './utils'; + +describe('getContactPointDescription', () => { + it('should show description for single integration', () => { + const contactPoint = ContactPointFactory.build({ + spec: { + integrations: [SlackIntegrationFactory.build()], + }, + }); + expect(getContactPointDescription(contactPoint)).toBe('slack'); + }); + + it('should show description for mixed contact points', () => { + const contactPoint = ContactPointFactory.build({ + spec: { + integrations: [EmailIntegrationFactory.build(), SlackIntegrationFactory.build()], + }, + }); + expect(getContactPointDescription(contactPoint)).toBe('email, slack'); + }); + + it('should show description for several of the same type', () => { + const contactPoint = ContactPointFactory.build({ + spec: { + integrations: EmailIntegrationFactory.buildList(3), + }, + }); + expect(getContactPointDescription(contactPoint)).toBe('email (3)'); + }); + + it('should show description for empty contact point', () => { + const contactPoint = ContactPointFactory.build({ spec: { integrations: [] } }); + expect(getContactPointDescription(contactPoint)).toBe(''); + }); + + it('should show description for generic / unknown contact point integration', () => { + const contactPoint = ContactPointFactory.build({ + spec: { integrations: [GenericIntegrationFactory.build({ type: 'generic' })] }, + }); + expect(getContactPointDescription(contactPoint)).toBe('generic'); + }); +}); diff --git a/packages/grafana-alerting/src/grafana/mocks/util.ts b/packages/grafana-alerting/src/grafana/mocks/util.ts new file mode 100644 index 00000000000..97be83066c8 --- /dev/null +++ b/packages/grafana-alerting/src/grafana/mocks/util.ts @@ -0,0 +1,15 @@ +import { faker } from '@faker-js/faker'; +import { upperFirst } from 'lodash'; + +export const DEFAULT_NAMESPACE = 'default' as const; + +// this function is used for MSW endpoints +export const getAPIBaseURLForMocks = (group: string, version: string, path: `/${string}` = '/') => + `/apis/${group}/${version}/namespaces/default${path}` as const; + +// example: "Likeable sea lion" +export const generateTitle = () => upperFirst(`${faker.word.adjective()} ${faker.animal.type()}`); + +export const generateResourceVersion = () => faker.string.alphanumeric({ length: 16, casing: 'lower' }); + +export const generateUID = () => faker.string.alphanumeric({ length: 32, casing: 'mixed' }); diff --git a/packages/grafana-alerting/src/unstable.ts b/packages/grafana-alerting/src/unstable.ts index 5f1d9d8b0a1..f1e7ce62356 100644 --- a/packages/grafana-alerting/src/unstable.ts +++ b/packages/grafana-alerting/src/unstable.ts @@ -5,7 +5,13 @@ // Contact Points export * from './grafana/api/v0alpha1/types'; export { useListContactPointsv0alpha1 } from './grafana/contactPoints/hooks/useContactPoints'; -export { ContactPointSelector } from './grafana/contactPoints/components/ContactPointSelector'; +export { ContactPointSelector } from './grafana/contactPoints/components/ContactPointSelector/ContactPointSelector'; // Low-level API hooks export { alertingAPI as alertingAPIv0alpha1 } from './grafana/api/v0alpha1/api.gen'; + +// model factories / mocks +export * as mocksV0alpha1 from './grafana/api/v0alpha1/mocks/fakes/Receivers'; + +// MSW handlers +export * as handlersV0alpha1 from './grafana/api/v0alpha1/mocks/handlers'; diff --git a/packages/grafana-alerting/tests/test-utils.tsx b/packages/grafana-alerting/tests/test-utils.tsx new file mode 100644 index 00000000000..ed25c27db8c --- /dev/null +++ b/packages/grafana-alerting/tests/test-utils.tsx @@ -0,0 +1,52 @@ +/** + * ⚠️ @TODO this will eventually be replaced with "@grafana/test-utils", consider helping out instead of adding things here! + */ +import { configureStore } from '@reduxjs/toolkit'; +import { type RenderOptions, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; + +import '@testing-library/jest-dom'; + +import { alertingAPIv0alpha1 } from '../src/unstable'; + +// create an empty store +const store = configureStore({ + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(alertingAPIv0alpha1.middleware), + reducer: { + [alertingAPIv0alpha1.reducerPath]: alertingAPIv0alpha1.reducer, + }, +}); + +/** + * Get a wrapper component that implements all of the providers that components + * within the app will need + */ +const getDefaultWrapper = ({}: RenderOptions) => { + /** + * Returns a wrapper that should (eventually?) match the main `AppWrapper`, so any tests are rendering + * in mostly the same providers as a "real" hierarchy + */ + return function Wrapper({ children }: React.PropsWithChildren) { + return {children}; + }; +}; + +/** + * Extended [@testing-library/react render](https://testing-library.com/docs/react-testing-library/api/#render) + * method which wraps the passed element in all of the necessary Providers, + * so it can render correctly in the context of the application + */ +const customRender = (ui: React.ReactNode, renderOptions: RenderOptions = {}) => { + const user = userEvent.setup(); + const Providers = renderOptions.wrapper || getDefaultWrapper(renderOptions); + + return { + renderResult: render(ui, { wrapper: Providers, ...renderOptions }), + user, + store, + }; +}; + +export * from '@testing-library/react'; +export { customRender as render, userEvent }; diff --git a/packages/grafana-alerting/tsconfig.json b/packages/grafana-alerting/tsconfig.json index 2ec42c374d6..aeb77e3b064 100644 --- a/packages/grafana-alerting/tsconfig.json +++ b/packages/grafana-alerting/tsconfig.json @@ -4,9 +4,16 @@ "declarationDir": "./compiled", "emitDeclarationOnly": true, "isolatedModules": true, - "rootDirs": ["."] + "rootDirs": ["."], + "moduleResolution": "bundler" }, "exclude": ["dist/**/*"], "extends": "@grafana/tsconfig", - "include": ["typings/jest", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts", "src/**/*.ts*"] + "include": ["typings/jest", "../../public/app/types/*.d.ts", "../grafana-ui/src/types/*.d.ts", "src/**/*.ts*"], + "ts-node": { + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "Node" + } + } } diff --git a/packages/grafana-test-utils/src/server/index.ts b/packages/grafana-test-utils/src/server/index.ts index 746f81cdc5c..5b0ba7b0d83 100644 --- a/packages/grafana-test-utils/src/server/index.ts +++ b/packages/grafana-test-utils/src/server/index.ts @@ -31,6 +31,8 @@ export function setupMockServer( afterAll(() => { server.close(); }); + + return server; } export default server; diff --git a/yarn.lock b/yarn.lock index cafb96c2d13..40b495ba95c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2291,6 +2291,13 @@ __metadata: languageName: node linkType: hard +"@faker-js/faker@npm:^9.7.0": + version: 9.7.0 + resolution: "@faker-js/faker@npm:9.7.0" + checksum: 10/5167cc179bbf5f140b957f600af24772ca8c6e7e42405c312c25a6b4278b851b84bd8e3065f80e667dd4c35dd5aeb0ed490b0b1872f908d58ffae1cef9a5ef72 + languageName: node + linkType: hard + "@fingerprintjs/fingerprintjs@npm:^3.4.2": version: 3.4.2 resolution: "@fingerprintjs/fingerprintjs@npm:3.4.2" @@ -2918,20 +2925,26 @@ __metadata: version: 0.0.0-use.local resolution: "@grafana/alerting@workspace:packages/grafana-alerting" dependencies: + "@faker-js/faker": "npm:^9.7.0" + "@grafana/test-utils": "workspace:*" "@grafana/tsconfig": "npm:^2.0.0" - "@reduxjs/toolkit": "npm:^2.8.0" "@rtk-query/codegen-openapi": "npm:^2.0.0" + "@testing-library/jest-dom": "npm:^6.6.3" + "@testing-library/react": "npm:^16.3.0" + "@testing-library/user-event": "npm:^14.6.1" "@types/lodash": "npm:^4" "@types/react": "npm:18.3.18" "@types/react-dom": "npm:18.3.5" lodash: "npm:^4.17.21" react: "npm:18.3.1" react-dom: "npm:18.3.1" + react-redux: "npm:^9.2.0" type-fest: "npm:^4.40.0" typescript: "npm:5.7.3" peerDependencies: "@grafana/runtime": ^12.0.0-pre "@grafana/ui": ^12.0.0-pre + "@reduxjs/toolkit": ^2.8.0 react: ^18.0.0 react-dom: ^18.0.0 languageName: unknown @@ -6553,28 +6566,6 @@ __metadata: languageName: node linkType: hard -"@reduxjs/toolkit@npm:^2.8.0": - version: 2.8.1 - resolution: "@reduxjs/toolkit@npm:2.8.1" - dependencies: - "@standard-schema/spec": "npm:^1.0.0" - "@standard-schema/utils": "npm:^0.3.0" - immer: "npm:^10.0.3" - redux: "npm:^5.0.1" - redux-thunk: "npm:^3.1.0" - reselect: "npm:^5.1.0" - peerDependencies: - react: ^16.9.0 || ^17.0.0 || ^18 || ^19 - react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 - peerDependenciesMeta: - react: - optional: true - react-redux: - optional: true - checksum: 10/388e39af72d42935bb644b8121c6b0de043bba51ab378f532476f7c77b79bde625f7c8dc5a6bc1095448008a08568c17876d958df87eeae1cfcb72214907e7da - languageName: node - linkType: hard - "@remix-run/router@npm:1.19.1": version: 1.19.1 resolution: "@remix-run/router@npm:1.19.1" @@ -7094,20 +7085,6 @@ __metadata: languageName: node linkType: hard -"@standard-schema/spec@npm:^1.0.0": - version: 1.0.0 - resolution: "@standard-schema/spec@npm:1.0.0" - checksum: 10/aee780cc1431888ca4b9aba9b24ffc8f3073fc083acc105e3951481478a2f4dc957796931b2da9e2d8329584cf211e4542275f188296c1cdff3ed44fd93a8bc8 - languageName: node - linkType: hard - -"@standard-schema/utils@npm:^0.3.0": - version: 0.3.0 - resolution: "@standard-schema/utils@npm:0.3.0" - checksum: 10/7084f875d322792f2e0a5904009434c8374b9345b09ba89828b68fd56fa3c2b366d35bf340d9e8c72736ef01793c2f70d350c372ed79845dc3566c58d34b4b51 - languageName: node - linkType: hard - "@storybook/addon-a11y@npm:^8.6.2": version: 8.6.2 resolution: "@storybook/addon-a11y@npm:8.6.2" @@ -8538,7 +8515,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:6.6.3, @testing-library/jest-dom@npm:^6.1.2": +"@testing-library/jest-dom@npm:6.6.3, @testing-library/jest-dom@npm:^6.1.2, @testing-library/jest-dom@npm:^6.6.3": version: 6.6.3 resolution: "@testing-library/jest-dom@npm:6.6.3" dependencies: @@ -8573,6 +8550,26 @@ __metadata: languageName: node linkType: hard +"@testing-library/react@npm:^16.3.0": + version: 16.3.0 + resolution: "@testing-library/react@npm:16.3.0" + dependencies: + "@babel/runtime": "npm:^7.12.5" + peerDependencies: + "@testing-library/dom": ^10.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/0ee9e31dd0d2396a924682d0e61a4ecc6bfab8eaff23dbf8a72c3c2ce22c116fa578148baeb4de75b968ef99d22e6e6aa0a00dba40286f71184918bb6bb5b06a + languageName: node + linkType: hard + "@testing-library/user-event@npm:14.5.2": version: 14.5.2 resolution: "@testing-library/user-event@npm:14.5.2" @@ -8582,7 +8579,7 @@ __metadata: languageName: node linkType: hard -"@testing-library/user-event@npm:14.6.1": +"@testing-library/user-event@npm:14.6.1, @testing-library/user-event@npm:^14.6.1": version: 14.6.1 resolution: "@testing-library/user-event@npm:14.6.1" peerDependencies: @@ -16750,11 +16747,11 @@ __metadata: linkType: hard "fishery@npm:^2.2.2": - version: 2.2.3 - resolution: "fishery@npm:2.2.3" + version: 2.3.1 + resolution: "fishery@npm:2.3.1" dependencies: lodash.mergewith: "npm:^4.6.2" - checksum: 10/a8855c1949524996d2ba3b84cf6fe13f2a8ade8296e090bedbc58d2879dc481d85a0aced1e33f80b1a1ec9cb1a2eb7562494c2ae514915730d14285694f595bf + checksum: 10/45011c4e9a8447357c2a1096db31db8175322f0193d05a00e3741c1bd1972c86451761f3b9cd25847421b16680f7e3a7c82ae7e010676b7e172d8a86040e394a languageName: node linkType: hard