Alerting: mocking and testing of Alerting package (#105342)

This commit is contained in:
Gilles De Mey
2025-05-21 12:47:31 +02:00
committed by GitHub
parent e2cd5c870f
commit 777d2e8e4a
29 changed files with 657 additions and 105 deletions

1
.github/CODEOWNERS vendored
View File

@ -440,6 +440,7 @@
/packages/grafana-ui/src/utils/storybook/ @grafana/grafana-frontend-platform /packages/grafana-ui/src/utils/storybook/ @grafana/grafana-frontend-platform
/packages/grafana-alerting/ @grafana/alerting-frontend /packages/grafana-alerting/ @grafana/alerting-frontend
/packages/grafana-i18n/ @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend /packages/grafana-i18n/ @grafana/grafana-frontend-platform @grafana/plugins-platform-frontend
/packages/grafana-test-utils @grafana/grafana-frontend-platform
# root files, mostly frontend # root files, mostly frontend
/.browserslistrc @grafana/frontend-ops /.browserslistrc @grafana/frontend-ops

View File

@ -40,24 +40,30 @@
"codegen": "rtk-query-codegen-openapi ./scripts/codegen.ts" "codegen": "rtk-query-codegen-openapi ./scripts/codegen.ts"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^9.7.0",
"@grafana/test-utils": "workspace:*",
"@grafana/tsconfig": "^2.0.0", "@grafana/tsconfig": "^2.0.0",
"@rtk-query/codegen-openapi": "^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/lodash": "^4",
"@types/react": "18.3.18", "@types/react": "18.3.18",
"@types/react-dom": "18.3.5", "@types/react-dom": "18.3.5",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-redux": "^9.2.0",
"type-fest": "^4.40.0", "type-fest": "^4.40.0",
"typescript": "5.7.3" "typescript": "5.7.3"
}, },
"peerDependencies": { "peerDependencies": {
"@grafana/runtime": "^12.0.0-pre", "@grafana/runtime": "^12.0.0-pre",
"@grafana/ui": "^12.0.0-pre", "@grafana/ui": "^12.0.0-pre",
"@reduxjs/toolkit": "^2.8.0",
"react": "^18.0.0", "react": "^18.0.0",
"react-dom": "^18.0.0" "react-dom": "^18.0.0"
}, },
"dependencies": { "dependencies": {
"@reduxjs/toolkit": "^2.8.0",
"lodash": "^4.17.21" "lodash": "^4.17.21"
} }
} }

View File

@ -9,6 +9,7 @@ import type { ConfigFile } from '@rtk-query/codegen-openapi';
// append versions here to generate additional API clients // append versions here to generate additional API clients
const VERSIONS = ['v0alpha1'] as const; const VERSIONS = ['v0alpha1'] as const;
const GROUP = 'notifications.alerting.grafana.app' as const;
type OutputFile = Omit<ConfigFile, 'outputFile'>; type OutputFile = Omit<ConfigFile, 'outputFile'>;
type OutputFiles = Record<string, OutputFile>; type OutputFiles = Record<string, OutputFile>;
@ -19,7 +20,7 @@ const outputFiles = VERSIONS.reduce<OutputFiles>((acc, version) => {
// these snapshots are generated by running "go test pkg/tests/apis/openapi_test.go" and "scripts/process-specs.ts", // 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 // 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 // make sure there is a API file in each versioned directory
const apiFile = `../src/grafana/api/${version}/api.ts`; const apiFile = `../src/grafana/api/${version}/api.ts`;

View File

@ -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 */ Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */
uid?: string; uid?: string;
}; };
export type Integration = { export type ReceiverIntegration = {
disableResolveMessage?: boolean; disableResolveMessage?: boolean;
secureFields?: { secureFields?: {
[key: string]: boolean; [key: string]: boolean;
@ -1251,9 +1251,59 @@ export type Integration = {
uid?: string; uid?: string;
}; };
export type ReceiverSpec = { export type ReceiverSpec = {
integrations: Integration[]; integrations: ReceiverIntegration[];
title: string; 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 = { 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. /** 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 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; 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 = { 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 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; 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 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; 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 Patch = object;
export type RouteDefaults = { export type RoutingTreeRouteDefaults = {
group_by?: string[]; group_by?: string[];
group_interval?: string; group_interval?: string;
group_wait?: string; group_wait?: string;
receiver: string; receiver: string;
repeat_interval?: string; repeat_interval?: string;
}; };
export type Matcher = { export type RoutingTreeMatcher = {
label: string; label: string;
type: string; type: string;
value: string; value: string;
}; };
export type Route = { export type RoutingTreeRoute = {
active_time_intervals?: string[];
continue: boolean; continue: boolean;
group_by?: string[]; group_by?: string[];
group_interval?: string; group_interval?: string;
group_wait?: string; group_wait?: string;
matchers?: Matcher[]; matchers?: RoutingTreeMatcher[];
mute_time_intervals?: string[]; mute_time_intervals?: string[];
receiver?: string; receiver?: string;
repeat_interval?: string; repeat_interval?: string;
routes?: Route[]; routes?: RoutingTreeRoute[];
}; };
export type RoutingtreeSpec = { export type RoutingTreeSpec = {
defaults: RouteDefaults; defaults: RoutingTreeRouteDefaults;
routes: Route[]; 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 = { 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 */ /** 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; kind?: string;
metadata: ObjectMeta; metadata: ObjectMeta;
/** Spec is the spec of the RoutingTree */ /** Spec is the spec of the RoutingTree */
spec: RoutingtreeSpec; spec: RoutingTreeSpec;
status: Status; status: RoutingTreeStatus;
}; };
export type RoutingTreeList = { 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 */ /** 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; kind?: string;
metadata: ListMeta; metadata: ListMeta;
}; };
export type TemplategroupSpec = { export type TemplateGroupSpec = {
content: string; content: string;
title: 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 = { 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 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; apiVersion?: string;
@ -1383,8 +1450,8 @@ export type TemplateGroup = {
kind?: string; kind?: string;
metadata: ObjectMeta; metadata: ObjectMeta;
/** Spec is the spec of the TemplateGroup */ /** Spec is the spec of the TemplateGroup */
spec: TemplategroupSpec; spec: TemplateGroupSpec;
status: Status; status: TemplateGroupStatus;
}; };
export type TemplateGroupList = { 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 */ /** 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; kind?: string;
metadata: ListMeta; metadata: ListMeta;
}; };
export type TimeRange = { export type TimeIntervalTimeRange = {
end_time: string; end_time: string;
start_time: string; start_time: string;
}; };
export type Interval = { export type TimeIntervalInterval = {
days_of_month?: string[]; days_of_month?: string[];
location?: string; location?: string;
months?: string[]; months?: string[];
times?: TimeRange[]; times?: TimeIntervalTimeRange[];
weekdays?: string[]; weekdays?: string[];
years?: string[]; years?: string[];
}; };
export type TimeintervalSpec = { export type TimeIntervalSpec = {
name: string; 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 = { 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 */ /** 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; kind?: string;
metadata: ObjectMeta; metadata: ObjectMeta;
/** Spec is the spec of the TimeInterval */ /** Spec is the spec of the TimeInterval */
spec: TimeintervalSpec; spec: TimeIntervalSpec;
status: Status; status: TimeIntervalStatus;
}; };
export type TimeIntervalList = { 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 */ /** 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 */

View File

@ -2,8 +2,7 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { getAPIBaseURL, getAPIReducerPath } from '../util'; import { getAPIBaseURL, getAPIReducerPath } from '../util';
const VERSION = 'v0alpha1'; import { GROUP, VERSION } from './const';
const GROUP = 'notifications.alerting.grafana.app';
const baseUrl = getAPIBaseURL(GROUP, VERSION); const baseUrl = getAPIBaseURL(GROUP, VERSION);
const reducerPath = getAPIReducerPath(GROUP, VERSION); const reducerPath = getAPIReducerPath(GROUP, VERSION);
@ -11,7 +10,9 @@ const reducerPath = getAPIReducerPath(GROUP, VERSION);
export const api = createApi({ export const api = createApi({
reducerPath, reducerPath,
baseQuery: fetchBaseQuery({ 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: () => ({}), endpoints: () => ({}),
}); });

View File

@ -0,0 +1,2 @@
export const VERSION = 'v0alpha1' as const;
export const GROUP = 'notifications.alerting.grafana.app' as const;

View File

@ -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<EnhancedListReceiverApiResponse>(() => ({
kind: 'ReceiverList',
apiVersion: `${GROUP}/${VERSION}`,
metadata: {},
items: ContactPointFactory.buildList(5),
}));
export const ContactPointFactory = Factory.define<ContactPoint>(() => {
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<ContactPoint['spec']>(() => ({
title: generateTitle(),
// use two unique random integrations by default
integrations: faker.helpers.uniqueArray(IntegrationUnion, 2).map((integration) => integration.build()),
}));
export const GenericIntegrationFactory = Factory.define<Integration>(() => ({
type: 'generic',
disableResolveMessage: false,
settings: {
foo: 'bar',
},
}));
export const EmailIntegrationFactory = Factory.define<Integration>(() => ({
type: 'email',
settings: {
addresses: faker.internet.email(),
},
}));
export const SlackIntegrationFactory = Factory.define<Integration>(() => ({
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<ContactPointMetadataAnnotations>(() => ({
'grafana.com/access/canReadSecrets': 'true',
'grafana.com/inUse/routes': '1',
'grafana.com/inUse/rules': '1',
...AlertingEntityMetadataAnnotationsFactory.build(),
}));

View File

@ -0,0 +1,10 @@
import { Factory } from 'fishery';
import { AlertingEntityMetadataAnnotations } from '../../types';
export const AlertingEntityMetadataAnnotationsFactory = Factory.define<AlertingEntityMetadataAnnotations>(() => ({
'grafana.com/access/canAdmin': 'true',
'grafana.com/access/canDelete': 'true',
'grafana.com/access/canWrite': 'true',
'grafana.com/provenance': '',
}));

View File

@ -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<Parameters<typeof http.post>[1]>[0]) => Response)
) {
return http.post(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) {
if (typeof data === 'function') {
return data(info);
}
return HttpResponse.json(data);
});
}

View File

@ -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<Parameters<typeof http.delete>[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);
});
}

View File

@ -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<Parameters<typeof http.delete>[1]>[0]) => Response)
) {
return http.delete(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) {
if (typeof data === 'function') {
return data(info);
}
return HttpResponse.json(data);
});
}

View File

@ -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<Parameters<typeof http.get>[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);
});
}

View File

@ -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';

View File

@ -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<Parameters<typeof http.get>[1]>[0]) => Response)
) {
return http.get(getAPIBaseURLForMocks(GROUP, VERSION, '/receivers'), function handler(info) {
if (typeof data === 'function') {
return data(info);
}
return HttpResponse.json(data);
});
}

View File

@ -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<Parameters<typeof http.put>[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);
});
}

View File

@ -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<Parameters<typeof http.patch>[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);
});
}

View File

@ -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';

View File

@ -3,7 +3,7 @@
*/ */
import { MergeDeep, MergeExclusive, OverrideProperties } from 'type-fest'; 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< type GenericIntegration = OverrideProperties<
ReceiverIntegration, ReceiverIntegration,
@ -58,12 +58,31 @@ export type Integration = EmailIntegration | SlackIntegration | GenericIntegrati
export type ContactPoint = MergeDeep< export type ContactPoint = MergeDeep<
Receiver, Receiver,
{ {
metadata: {
annotations: ContactPointMetadataAnnotations;
};
spec: { spec: {
integrations: Integration[]; 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< export type EnhancedListReceiverApiResponse = OverrideProperties<
ListReceiverApiResponse, ListReceiverApiResponse,
{ {

View File

@ -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;

View File

@ -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(<ContactPointSelector onChange={onChangeHandler} />);
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);
});
});

View File

@ -2,9 +2,9 @@ import { chain } from 'lodash';
import { Combobox, ComboboxOption } from '@grafana/ui'; import { Combobox, ComboboxOption } from '@grafana/ui';
import { ContactPoint } from '../../api/v0alpha1/types'; import type { ContactPoint } from '../../../api/v0alpha1/types';
import { useListContactPointsv0alpha1 } from '../hooks/useContactPoints'; import { useListContactPointsv0alpha1 } from '../../hooks/useContactPoints';
import { getContactPointDescription } from '../utils'; import { getContactPointDescription } from '../../utils';
const collator = new Intl.Collator('en', { sensitivity: 'accent' }); const collator = new Intl.Collator('en', { sensitivity: 'accent' });

View File

@ -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 ListReceiverApiArg, alertingAPI } from '../../api/v0alpha1/api.gen';
import type { EnhancedListReceiverApiResponse } from '../../api/v0alpha1/types'; import type { EnhancedListReceiverApiResponse } from '../../api/v0alpha1/types';

View File

@ -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('<empty contact point>');
});
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');
});
});

View File

@ -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' });

View File

@ -5,7 +5,13 @@
// Contact Points // Contact Points
export * from './grafana/api/v0alpha1/types'; export * from './grafana/api/v0alpha1/types';
export { useListContactPointsv0alpha1 } from './grafana/contactPoints/hooks/useContactPoints'; 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 // Low-level API hooks
export { alertingAPI as alertingAPIv0alpha1 } from './grafana/api/v0alpha1/api.gen'; 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';

View File

@ -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 <Provider store={store}>{children}</Provider>;
};
};
/**
* 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 };

View File

@ -4,9 +4,16 @@
"declarationDir": "./compiled", "declarationDir": "./compiled",
"emitDeclarationOnly": true, "emitDeclarationOnly": true,
"isolatedModules": true, "isolatedModules": true,
"rootDirs": ["."] "rootDirs": ["."],
"moduleResolution": "bundler"
}, },
"exclude": ["dist/**/*"], "exclude": ["dist/**/*"],
"extends": "@grafana/tsconfig", "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"
}
}
} }

View File

@ -31,6 +31,8 @@ export function setupMockServer(
afterAll(() => { afterAll(() => {
server.close(); server.close();
}); });
return server;
} }
export default server; export default server;

View File

@ -2291,6 +2291,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@fingerprintjs/fingerprintjs@npm:^3.4.2":
version: 3.4.2 version: 3.4.2
resolution: "@fingerprintjs/fingerprintjs@npm:3.4.2" resolution: "@fingerprintjs/fingerprintjs@npm:3.4.2"
@ -2918,20 +2925,26 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@grafana/alerting@workspace:packages/grafana-alerting" resolution: "@grafana/alerting@workspace:packages/grafana-alerting"
dependencies: dependencies:
"@faker-js/faker": "npm:^9.7.0"
"@grafana/test-utils": "workspace:*"
"@grafana/tsconfig": "npm:^2.0.0" "@grafana/tsconfig": "npm:^2.0.0"
"@reduxjs/toolkit": "npm:^2.8.0"
"@rtk-query/codegen-openapi": "npm:^2.0.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/lodash": "npm:^4"
"@types/react": "npm:18.3.18" "@types/react": "npm:18.3.18"
"@types/react-dom": "npm:18.3.5" "@types/react-dom": "npm:18.3.5"
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
react: "npm:18.3.1" react: "npm:18.3.1"
react-dom: "npm:18.3.1" react-dom: "npm:18.3.1"
react-redux: "npm:^9.2.0"
type-fest: "npm:^4.40.0" type-fest: "npm:^4.40.0"
typescript: "npm:5.7.3" typescript: "npm:5.7.3"
peerDependencies: peerDependencies:
"@grafana/runtime": ^12.0.0-pre "@grafana/runtime": ^12.0.0-pre
"@grafana/ui": ^12.0.0-pre "@grafana/ui": ^12.0.0-pre
"@reduxjs/toolkit": ^2.8.0
react: ^18.0.0 react: ^18.0.0
react-dom: ^18.0.0 react-dom: ^18.0.0
languageName: unknown languageName: unknown
@ -6553,28 +6566,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@remix-run/router@npm:1.19.1":
version: 1.19.1 version: 1.19.1
resolution: "@remix-run/router@npm:1.19.1" resolution: "@remix-run/router@npm:1.19.1"
@ -7094,20 +7085,6 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@storybook/addon-a11y@npm:^8.6.2":
version: 8.6.2 version: 8.6.2
resolution: "@storybook/addon-a11y@npm:8.6.2" resolution: "@storybook/addon-a11y@npm:8.6.2"
@ -8538,7 +8515,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 6.6.3
resolution: "@testing-library/jest-dom@npm:6.6.3" resolution: "@testing-library/jest-dom@npm:6.6.3"
dependencies: dependencies:
@ -8573,6 +8550,26 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@testing-library/user-event@npm:14.5.2":
version: 14.5.2 version: 14.5.2
resolution: "@testing-library/user-event@npm:14.5.2" resolution: "@testing-library/user-event@npm:14.5.2"
@ -8582,7 +8579,7 @@ __metadata:
languageName: node languageName: node
linkType: hard 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 version: 14.6.1
resolution: "@testing-library/user-event@npm:14.6.1" resolution: "@testing-library/user-event@npm:14.6.1"
peerDependencies: peerDependencies:
@ -16750,11 +16747,11 @@ __metadata:
linkType: hard linkType: hard
"fishery@npm:^2.2.2": "fishery@npm:^2.2.2":
version: 2.2.3 version: 2.3.1
resolution: "fishery@npm:2.2.3" resolution: "fishery@npm:2.3.1"
dependencies: dependencies:
lodash.mergewith: "npm:^4.6.2" lodash.mergewith: "npm:^4.6.2"
checksum: 10/a8855c1949524996d2ba3b84cf6fe13f2a8ade8296e090bedbc58d2879dc481d85a0aced1e33f80b1a1ec9cb1a2eb7562494c2ae514915730d14285694f595bf checksum: 10/45011c4e9a8447357c2a1096db31db8175322f0193d05a00e3741c1bd1972c86451761f3b9cd25847421b16680f7e3a7c82ae7e010676b7e172d8a86040e394a
languageName: node languageName: node
linkType: hard linkType: hard