Merge pull request #32734 from backstage/rugvip/test-utils
frontend-test-utils: more mockApis and utilities
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-test-utils': minor
|
||||
---
|
||||
|
||||
**BREAKING**: Removed the `TestApiRegistry` class, use `TestApiProvider` directly instead, storing reused APIs in a variable, e.g. `const apis = [...] as const`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
The `catalogApiMock` test utility now returns a `MockWithApiFactory`, allowing it to be passed directly to test utilities like `renderTestApp` and `TestApiProvider` without needing the `[catalogApiRef, catalogApiMock()]` tuple.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-test-utils': minor
|
||||
---
|
||||
|
||||
Added `MockAlertApi` and `MockFeatureFlagsApi` implementations to the `mockApis` namespace. The mock implementations include useful testing methods like `clearAlerts()`, `waitForAlert()`, `getState()`, `setState()`, and `clearState()` for better test ergonomics.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
'@backstage/frontend-test-utils': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `mockApis` namespace is no longer a re-export from `@backstage/test-utils`. It's now a standalone namespace with mock implementations of most core APIs. Mock API instances can be passed directly to `TestApiProvider`, `renderInTestApp`, and `renderTestApp` without needing `[apiRef, impl]` tuples. As part of this change, the `.factory()` method on some mocks has been removed, since it's now redundant.
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
|
||||
renderInTestApp(<MyComponent />, {
|
||||
apis: [[identityApiRef, mockApis.identity()]],
|
||||
});
|
||||
|
||||
// After - mock APIs can be passed directly
|
||||
renderInTestApp(<MyComponent />, {
|
||||
apis: [mockApis.identity()],
|
||||
});
|
||||
```
|
||||
|
||||
This change also adds `createApiMock`, a public utility for creating mock API factories, intended for plugin authors to create their own `.mock()` variants.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/repo-tools': patch
|
||||
---
|
||||
|
||||
The `type-deps` command now detects ambient global types from the `jest` namespace in declaration files, rather than only looking for explicit imports and reference directives.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-react': patch
|
||||
---
|
||||
|
||||
Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
---
|
||||
|
||||
Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/test-utils': patch
|
||||
'@backstage/backend-test-utils': patch
|
||||
---
|
||||
|
||||
Added `@types/jest` as an optional peer dependency, since jest types are exposed in the public API surface.
|
||||
@@ -53,7 +53,6 @@ Chai
|
||||
Chainguard
|
||||
changeset
|
||||
changesets
|
||||
Changesets
|
||||
chanwit
|
||||
Chanwit
|
||||
CI/CD
|
||||
@@ -68,12 +67,10 @@ Cobertura
|
||||
codeblocks
|
||||
Codecov
|
||||
codehilite
|
||||
Codehilite
|
||||
codemod
|
||||
codemods
|
||||
codeowners
|
||||
codescene
|
||||
CodeScene
|
||||
composability
|
||||
composable
|
||||
config
|
||||
@@ -125,7 +122,6 @@ devs
|
||||
dialogs
|
||||
disambiguator
|
||||
discoverability
|
||||
Discoverability
|
||||
dls
|
||||
Dockerfile
|
||||
dockerfiles
|
||||
@@ -136,7 +132,6 @@ Dominik
|
||||
DOMPurify
|
||||
don'ts
|
||||
dynatrace
|
||||
Dynatrace
|
||||
dyno
|
||||
ecco
|
||||
elasticsearch
|
||||
@@ -157,12 +152,10 @@ faqs
|
||||
featureful
|
||||
Figma
|
||||
firehydrant
|
||||
FireHydrant
|
||||
Firekube
|
||||
Firestore
|
||||
Fiverr
|
||||
flightcontrol
|
||||
Flightcontrol
|
||||
Francesco
|
||||
Frontside
|
||||
Gaurav
|
||||
@@ -188,7 +181,6 @@ haproxy
|
||||
hardcoded
|
||||
hardcoding
|
||||
harness
|
||||
Harness
|
||||
Helidon
|
||||
Henneke
|
||||
Heroku
|
||||
@@ -281,7 +273,6 @@ microsite
|
||||
microtasks
|
||||
middleware
|
||||
minikube
|
||||
Minikube
|
||||
Minio
|
||||
misattributed
|
||||
misconfiguration
|
||||
@@ -290,7 +281,6 @@ mkdocs
|
||||
Mkdocs
|
||||
modularization
|
||||
monorepo
|
||||
Monorepo
|
||||
monorepos
|
||||
monospace
|
||||
morgan
|
||||
@@ -327,7 +317,6 @@ Olausson
|
||||
Oldsberg
|
||||
onboarded
|
||||
onboarding
|
||||
Onboarding
|
||||
onelogin
|
||||
openapi
|
||||
OpenSearch
|
||||
@@ -335,7 +324,6 @@ OpenShift
|
||||
openssl
|
||||
orgs
|
||||
overridable
|
||||
Overridable
|
||||
padding
|
||||
paddings
|
||||
pagerduty
|
||||
@@ -345,13 +333,13 @@ parallelization
|
||||
param
|
||||
params
|
||||
parseable
|
||||
passthrough
|
||||
passwordless
|
||||
Patrik
|
||||
pattison
|
||||
Peloton
|
||||
PEP
|
||||
performant
|
||||
Performant
|
||||
periskop
|
||||
Periskop
|
||||
permissioned
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('Entity details component', () => {
|
||||
});
|
||||
```
|
||||
|
||||
To mock [Utility APIs](../architecture/33-utility-apis.md) that are used by your component, pass API overrides to `renderInTestApp` using the `apis` option. Mock helpers are available from `@backstage/frontend-test-utils` and plugin-specific test utilities:
|
||||
To mock [Utility APIs](../architecture/33-utility-apis.md) that are used by your component, pass API overrides to `renderInTestApp` using the `apis` option. Mock helpers are available from `@backstage/frontend-test-utils` and plugin-specific test utilities. For a deeper look at the available mock APIs and how to create your own, see [Testing with Utility APIs](../utility-apis/05-testing.md).
|
||||
|
||||
```tsx
|
||||
import { screen } from '@testing-library/react';
|
||||
|
||||
@@ -35,6 +35,14 @@ Most utility APIs are usable directly without any configuration. But they are pr
|
||||
|
||||
These cases are all described in [the main article](./04-configuring.md).
|
||||
|
||||
## Testing with utility APIs
|
||||
|
||||
> For details, [see the main article](./05-testing.md).
|
||||
|
||||
When testing frontend components and extensions, you often need to provide mock implementations of the utility APIs they depend on. The `@backstage/frontend-test-utils` package provides the `mockApis` namespace with ready-made mocks for all core utility APIs, which can be passed to test utilities like `renderInTestApp` and `TestApiProvider`.
|
||||
|
||||
These are described in detail in [the main article](./05-testing.md).
|
||||
|
||||
## Migrating from the old frontend system
|
||||
|
||||
If you want to learn how to migrate your own utility APIs from the old frontend system to the new one, that's described in the [Migrating APIs guide](../building-plugins/05-migrating.md#migrating-apis).
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
---
|
||||
id: testing
|
||||
title: Testing with Utility APIs
|
||||
sidebar_label: Testing
|
||||
description: Mocking and testing Utility APIs
|
||||
---
|
||||
|
||||
When testing frontend components and extensions, you often need to provide mock implementations of the utility APIs they depend on. The `@backstage/frontend-test-utils` package provides the `mockApis` namespace with ready-made mocks for all core utility APIs.
|
||||
|
||||
## The `mockApis` namespace
|
||||
|
||||
The `mockApis` namespace is the main entry point for creating mock utility API instances in tests. It provides two usage patterns for each API:
|
||||
|
||||
### Fake instances
|
||||
|
||||
Call the API function directly to create a fake instance with simplified but functional behavior. These are useful when your test needs the API to actually work, not just be stubbed.
|
||||
|
||||
```ts
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
|
||||
const configApi = mockApis.config({ data: { app: { title: 'Test' } } });
|
||||
configApi.getString('app.title'); // 'Test'
|
||||
|
||||
const alertApi = mockApis.alert();
|
||||
alertApi.post({ message: 'hello' });
|
||||
expect(alertApi.getAlerts()).toHaveLength(1);
|
||||
```
|
||||
|
||||
### Jest mocks
|
||||
|
||||
Call `.mock()` to get an instance where every method is a `jest.fn()`. You can optionally provide partial implementations. This is useful when you want to assert that specific methods were called.
|
||||
|
||||
```ts
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
|
||||
const permissionApi = mockApis.permission.mock({
|
||||
authorize: async () => ({ result: AuthorizeResult.ALLOW }),
|
||||
});
|
||||
|
||||
// ... exercise the component ...
|
||||
|
||||
expect(permissionApi.authorize).toHaveBeenCalledTimes(1);
|
||||
```
|
||||
|
||||
## Providing mock APIs in tests
|
||||
|
||||
### With `renderInTestApp`
|
||||
|
||||
```tsx
|
||||
import { screen } from '@testing-library/react';
|
||||
import { renderInTestApp, mockApis } from '@backstage/frontend-test-utils';
|
||||
|
||||
await renderInTestApp(<MyComponent />, {
|
||||
apis: [
|
||||
mockApis.identity({ userEntityRef: 'user:default/guest' }),
|
||||
mockApis.config({ data: { app: { title: 'Test App' } } }),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
You can also use the `[apiRef, implementation]` tuple syntax to provide any API implementation, including ones that aren't from `mockApis`:
|
||||
|
||||
```tsx
|
||||
import { myCustomApiRef } from '../apis';
|
||||
|
||||
const myCustomApiInstance = {
|
||||
// ...
|
||||
};
|
||||
|
||||
await renderInTestApp(<MyComponent />, {
|
||||
apis: [
|
||||
mockApis.identity({ userEntityRef: 'user:default/guest' }),
|
||||
[myCustomApiRef, myCustomApiInstance],
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### With `renderTestApp`
|
||||
|
||||
The same `apis` option is available on `renderTestApp`, which is commonly used when testing extensions or entity pages:
|
||||
|
||||
```tsx
|
||||
import { renderTestApp, mockApis } from '@backstage/frontend-test-utils';
|
||||
import { createTestEntityPage } from '@backstage/plugin-catalog-react/testUtils';
|
||||
|
||||
renderTestApp({
|
||||
extensions: [createTestEntityPage({ entity }), myEntityCard],
|
||||
apis: [mockApis.permission()],
|
||||
});
|
||||
```
|
||||
|
||||
### With `TestApiProvider`
|
||||
|
||||
For standalone rendering scenarios where you're not using `renderInTestApp`, the `TestApiProvider` component accepts the same `apis` format:
|
||||
|
||||
```tsx
|
||||
import { render } from '@testing-library/react';
|
||||
import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils';
|
||||
|
||||
render(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
mockApis.identity({ userEntityRef: 'user:default/guest' }),
|
||||
mockApis.alert(),
|
||||
]}
|
||||
>
|
||||
<MyComponent />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
```
|
||||
|
||||
## Plugin-specific test mocks
|
||||
|
||||
Plugins can provide their own mock APIs that follow the same pattern. For example, `@backstage/plugin-catalog-react` provides `catalogApiMock` in its `/testUtils` entry point:
|
||||
|
||||
```tsx
|
||||
import { renderTestApp } from '@backstage/frontend-test-utils';
|
||||
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
|
||||
renderTestApp({
|
||||
extensions: [myExtension],
|
||||
apis: [catalogApiMock({ entities: [entity] })],
|
||||
});
|
||||
```
|
||||
|
||||
### Creating your own mock APIs
|
||||
|
||||
If you maintain a plugin that exposes a utility API, you can provide mock utilities that follow the same function + `.mock()` pattern as the built-in `mockApis`.
|
||||
|
||||
Use `attachMockApiFactory` for fake instances with real behavior, and `createApiMock` for the jest-mocked `.mock()` variant where all methods are `jest.fn()`. The full pattern looks like this:
|
||||
|
||||
```ts
|
||||
import {
|
||||
attachMockApiFactory,
|
||||
createApiMock,
|
||||
type ApiMock,
|
||||
} from '@backstage/frontend-test-utils';
|
||||
import { myApiRef, type MyApi } from '@internal/plugin-example-react';
|
||||
|
||||
// Fake instance with real behavior
|
||||
export function myApiMock(options?: { greeting?: string }) {
|
||||
return attachMockApiFactory(myApiRef, {
|
||||
greet: async () => options?.greeting ?? 'Hello!',
|
||||
});
|
||||
}
|
||||
|
||||
// Jest mock variant where all methods are jest.fn()
|
||||
export namespace myApiMock {
|
||||
export const mock = createApiMock(myApiRef, () => ({
|
||||
greet: jest.fn(),
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Consumers can then use it just like the core mocks:
|
||||
|
||||
```tsx
|
||||
// Fake with real behavior
|
||||
await renderInTestApp(<MyComponent />, {
|
||||
apis: [myApiMock({ greeting: 'Hi there!' })],
|
||||
});
|
||||
|
||||
// Jest mock for assertions
|
||||
const api = myApiMock.mock({
|
||||
greet: async () => 'mocked',
|
||||
});
|
||||
|
||||
await renderInTestApp(<MyComponent />, {
|
||||
apis: [api],
|
||||
});
|
||||
|
||||
expect(api.greet).toHaveBeenCalledTimes(1);
|
||||
```
|
||||
|
||||
## Available mock APIs
|
||||
|
||||
The table below lists all core APIs available through the `mockApis` namespace.
|
||||
|
||||
| API | Fake instance | Notes |
|
||||
| --------------------------------- | --------------------- | ---------------------------------------------------------------------- |
|
||||
| `mockApis.alert()` | `MockAlertApi` | Collects alerts; has `getAlerts()`, `clearAlerts()`, `waitForAlert()` |
|
||||
| `mockApis.analytics()` | `MockAnalyticsApi` | Collects events; has `getEvents()` |
|
||||
| `mockApis.config({ data })` | `MockConfigApi` | Reads from a plain JSON object |
|
||||
| `mockApis.discovery({ baseUrl })` | Inline | Returns `${baseUrl}/api/${pluginId}`, defaults to `http://example.com` |
|
||||
| `mockApis.error(options?)` | `MockErrorApi` | Collects errors; has `getErrors()`, `waitForError()` |
|
||||
| `mockApis.featureFlags(options?)` | `MockFeatureFlagsApi` | In-memory flag state; has `getState()`, `setState()`, `clearState()` |
|
||||
| `mockApis.fetch(options?)` | `MockFetchApi` | Wraps native `fetch`; supports identity injection and plugin protocol |
|
||||
| `mockApis.identity(options?)` | Inline | Configurable user ref, ownership, token, profile |
|
||||
| `mockApis.permission(options?)` | `MockPermissionApi` | Defaults to `ALLOW`; accepts a handler function |
|
||||
| `mockApis.storage({ data })` | `MockStorageApi` | In-memory storage with bucket support |
|
||||
| `mockApis.translation()` | `MockTranslationApi` | Passthrough returning default messages from translation refs |
|
||||
|
||||
Each of these also has a `.mock()` variant that returns jest mocks, as described above.
|
||||
@@ -599,6 +599,7 @@ export default {
|
||||
'frontend-system/utility-apis/creating',
|
||||
'frontend-system/utility-apis/consuming',
|
||||
'frontend-system/utility-apis/configuring',
|
||||
'frontend-system/utility-apis/testing',
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -88,5 +88,13 @@
|
||||
"@types/lodash": "^4.14.151",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^7.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/jest": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/jest": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,31 +32,44 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-app-api": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/plugin-app": "workspace:^",
|
||||
"@backstage/plugin-app-react": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/plugin-permission-react": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"i18next": "^22.4.15",
|
||||
"zen-observable": "^0.10.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@types/jest": "*",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/zen-observable": "^0.8.0",
|
||||
"msw": "^2.0.0",
|
||||
"react": "^18.0.2",
|
||||
"react-dom": "^18.0.2",
|
||||
"react-router-dom": "^6.30.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/jest": "*",
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0",
|
||||
"react-router-dom": "^6.30.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/jest": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
|
||||
@@ -3,37 +3,77 @@
|
||||
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
|
||||
|
||||
```ts
|
||||
import { AlertApi } from '@backstage/frontend-plugin-api';
|
||||
import { AlertMessage } from '@backstage/frontend-plugin-api';
|
||||
import { AnalyticsApi } from '@backstage/frontend-plugin-api';
|
||||
import { AnalyticsEvent } from '@backstage/frontend-plugin-api';
|
||||
import { ApiHolder } from '@backstage/frontend-plugin-api';
|
||||
import { ApiMock } from '@backstage/test-utils';
|
||||
import { ApiFactory } from '@backstage/frontend-plugin-api';
|
||||
import { ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { AppNode } from '@backstage/frontend-plugin-api';
|
||||
import { AppNodeInstance } from '@backstage/frontend-plugin-api';
|
||||
import { ErrorWithContext } from '@backstage/test-utils';
|
||||
import { AuthorizeResult } from '@backstage/plugin-permission-common';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { ConfigApi as ConfigApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { DiscoveryApi } from '@backstage/frontend-plugin-api';
|
||||
import { DiscoveryApi as DiscoveryApi_2 } from '@backstage/core-plugin-api';
|
||||
import { ErrorApi } from '@backstage/core-plugin-api';
|
||||
import { ErrorApi as ErrorApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { ErrorApiError } from '@backstage/core-plugin-api';
|
||||
import { ErrorApiErrorContext } from '@backstage/core-plugin-api';
|
||||
import { EvaluatePermissionRequest } from '@backstage/plugin-permission-common';
|
||||
import { EvaluatePermissionResponse } from '@backstage/plugin-permission-common';
|
||||
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
|
||||
import { ExtensionDefinitionParameters } from '@backstage/frontend-plugin-api';
|
||||
import { FeatureFlag } from '@backstage/frontend-plugin-api';
|
||||
import { FeatureFlagsApi } from '@backstage/frontend-plugin-api';
|
||||
import { FeatureFlagsSaveOptions } from '@backstage/frontend-plugin-api';
|
||||
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
|
||||
import { FetchApi } from '@backstage/core-plugin-api';
|
||||
import { FetchApi as FetchApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { IdentityApi } from '@backstage/frontend-plugin-api';
|
||||
import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
||||
import { mockApis } from '@backstage/test-utils';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
import { MockErrorApi } from '@backstage/test-utils';
|
||||
import { MockErrorApiOptions } from '@backstage/test-utils';
|
||||
import { MockFetchApi } from '@backstage/test-utils';
|
||||
import { MockFetchApiOptions } from '@backstage/test-utils';
|
||||
import { MockPermissionApi } from '@backstage/test-utils';
|
||||
import { MockStorageApi } from '@backstage/test-utils';
|
||||
import { MockStorageBucket } from '@backstage/test-utils';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { Observable } from '@backstage/types';
|
||||
import { PermissionApi } from '@backstage/plugin-permission-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { registerMswTestHooks } from '@backstage/test-utils';
|
||||
import { RenderResult } from '@testing-library/react';
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries';
|
||||
import { StorageApi } from '@backstage/core-plugin-api';
|
||||
import { StorageApi as StorageApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { StorageValueSnapshot } from '@backstage/core-plugin-api';
|
||||
import { TranslationApi } from '@backstage/core-plugin-api/alpha';
|
||||
import { TranslationApi as TranslationApi_2 } from '@backstage/frontend-plugin-api';
|
||||
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { TranslationSnapshot } from '@backstage/core-plugin-api/alpha';
|
||||
import { withLogCollector } from '@backstage/test-utils';
|
||||
|
||||
export { ApiMock };
|
||||
// @public
|
||||
export type ApiMock<TApi> = {
|
||||
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
|
||||
} & {
|
||||
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
|
||||
? TApi[Key] & jest.MockInstance<Return, Args>
|
||||
: TApi[Key];
|
||||
};
|
||||
|
||||
// @public
|
||||
export function attachMockApiFactory<TApi, TImpl extends TApi = TApi>(
|
||||
apiRef: ApiRef<TApi>,
|
||||
implementation: TImpl,
|
||||
): TImpl & {
|
||||
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function createApiMock<TApi>(
|
||||
apiRef: ApiRef<TApi>,
|
||||
mockFactory: () => jest.Mocked<TApi>,
|
||||
): (partialImpl?: Partial<TApi>) => ApiMock<TApi>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createExtensionTester<
|
||||
@@ -47,7 +87,11 @@ export function createExtensionTester<
|
||||
},
|
||||
): ExtensionTester<NonNullable<T['output']>>;
|
||||
|
||||
export { ErrorWithContext };
|
||||
// @public
|
||||
export type ErrorWithContext = {
|
||||
error: ErrorApiError;
|
||||
context?: ErrorApiErrorContext;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export class ExtensionQuery<UOutput extends ExtensionDataRef> {
|
||||
@@ -100,6 +144,20 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
|
||||
snapshot(): ExtensionSnapshotNode;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class MockAlertApi implements AlertApi {
|
||||
// (undocumented)
|
||||
alert$(): Observable<AlertMessage>;
|
||||
clearAlerts(): void;
|
||||
getAlerts(): AlertMessage[];
|
||||
// (undocumented)
|
||||
post(alert: AlertMessage): void;
|
||||
waitForAlert(
|
||||
predicate: (alert: AlertMessage) => boolean,
|
||||
timeoutMs?: number,
|
||||
): Promise<AlertMessage>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class MockAnalyticsApi implements AnalyticsApi {
|
||||
// (undocumented)
|
||||
@@ -108,36 +166,268 @@ export class MockAnalyticsApi implements AnalyticsApi {
|
||||
getEvents(): AnalyticsEvent[];
|
||||
}
|
||||
|
||||
export { mockApis };
|
||||
// @public
|
||||
export type MockApiFactorySymbol = typeof mockApiFactorySymbol;
|
||||
|
||||
export { MockConfigApi };
|
||||
// @public
|
||||
export namespace mockApis {
|
||||
export function alert(): MockWithApiFactory<MockAlertApi>;
|
||||
export namespace alert {
|
||||
const mock: (
|
||||
partialImpl?: Partial<AlertApi> | undefined,
|
||||
) => ApiMock<AlertApi>;
|
||||
}
|
||||
export function analytics(): MockAnalyticsApi &
|
||||
MockWithApiFactory<AnalyticsApi>;
|
||||
export namespace analytics {
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<AnalyticsApi> | undefined,
|
||||
) => ApiMock<AnalyticsApi>;
|
||||
}
|
||||
export function config(options?: {
|
||||
data?: JsonObject;
|
||||
}): MockConfigApi & MockWithApiFactory<ConfigApi_2>;
|
||||
export namespace config {
|
||||
const // (undocumented)
|
||||
mock: (partialImpl?: Partial<Config> | undefined) => ApiMock<Config>;
|
||||
}
|
||||
export function discovery(options?: {
|
||||
baseUrl?: string;
|
||||
}): DiscoveryApi & MockWithApiFactory<DiscoveryApi>;
|
||||
export namespace discovery {
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<DiscoveryApi> | undefined,
|
||||
) => ApiMock<DiscoveryApi>;
|
||||
}
|
||||
export function error(
|
||||
options?: MockErrorApiOptions,
|
||||
): MockErrorApi & MockWithApiFactory<ErrorApi_2>;
|
||||
export namespace error {
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<ErrorApi_2> | undefined,
|
||||
) => ApiMock<ErrorApi_2>;
|
||||
}
|
||||
export function featureFlags(
|
||||
options?: MockFeatureFlagsApiOptions,
|
||||
): MockWithApiFactory<MockFeatureFlagsApi>;
|
||||
export namespace featureFlags {
|
||||
const mock: (
|
||||
partialImpl?: Partial<FeatureFlagsApi> | undefined,
|
||||
) => ApiMock<FeatureFlagsApi>;
|
||||
}
|
||||
export function fetch(
|
||||
options?: MockFetchApiOptions,
|
||||
): MockFetchApi & MockWithApiFactory<FetchApi_2>;
|
||||
export namespace fetch {
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<FetchApi_2> | undefined,
|
||||
) => ApiMock<FetchApi_2>;
|
||||
}
|
||||
export function identity(options?: {
|
||||
userEntityRef?: string;
|
||||
ownershipEntityRefs?: string[];
|
||||
token?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
picture?: string;
|
||||
}): MockWithApiFactory<IdentityApi>;
|
||||
export namespace identity {
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<IdentityApi> | undefined,
|
||||
) => ApiMock<IdentityApi>;
|
||||
}
|
||||
export function permission(options?: {
|
||||
authorize?:
|
||||
| AuthorizeResult.ALLOW
|
||||
| AuthorizeResult.DENY
|
||||
| ((
|
||||
request: EvaluatePermissionRequest,
|
||||
) => AuthorizeResult.ALLOW | AuthorizeResult.DENY);
|
||||
}): MockPermissionApi & MockWithApiFactory<PermissionApi>;
|
||||
export namespace permission {
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<PermissionApi> | undefined,
|
||||
) => ApiMock<PermissionApi>;
|
||||
}
|
||||
export function storage(options?: {
|
||||
data?: JsonObject;
|
||||
}): MockStorageApi & MockWithApiFactory<StorageApi_2>;
|
||||
export namespace storage {
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<StorageApi_2> | undefined,
|
||||
) => ApiMock<StorageApi_2>;
|
||||
}
|
||||
export function translation(): MockTranslationApi &
|
||||
MockWithApiFactory<TranslationApi_2>;
|
||||
export namespace translation {
|
||||
const mock: (
|
||||
partialImpl?: Partial<TranslationApi_2> | undefined,
|
||||
) => ApiMock<TranslationApi_2>;
|
||||
}
|
||||
}
|
||||
|
||||
export { MockErrorApi };
|
||||
// @public
|
||||
export class MockConfigApi implements ConfigApi {
|
||||
constructor({ data }: { data: JsonObject });
|
||||
get<T = JsonValue>(key?: string): T;
|
||||
getBoolean(key: string): boolean;
|
||||
getConfig(key: string): Config;
|
||||
getConfigArray(key: string): Config[];
|
||||
getNumber(key: string): number;
|
||||
getOptional<T = JsonValue>(key?: string): T | undefined;
|
||||
getOptionalBoolean(key: string): boolean | undefined;
|
||||
getOptionalConfig(key: string): Config | undefined;
|
||||
getOptionalConfigArray(key: string): Config[] | undefined;
|
||||
getOptionalNumber(key: string): number | undefined;
|
||||
getOptionalString(key: string): string | undefined;
|
||||
getOptionalStringArray(key: string): string[] | undefined;
|
||||
getString(key: string): string;
|
||||
getStringArray(key: string): string[];
|
||||
has(key: string): boolean;
|
||||
keys(): string[];
|
||||
}
|
||||
|
||||
export { MockErrorApiOptions };
|
||||
// @public
|
||||
export class MockErrorApi implements ErrorApi {
|
||||
constructor(options?: MockErrorApiOptions);
|
||||
// (undocumented)
|
||||
error$(): Observable<{
|
||||
error: ErrorApiError;
|
||||
context?: ErrorApiErrorContext;
|
||||
}>;
|
||||
// (undocumented)
|
||||
getErrors(): ErrorWithContext[];
|
||||
// (undocumented)
|
||||
post(error: ErrorApiError, context?: ErrorApiErrorContext): void;
|
||||
// (undocumented)
|
||||
waitForError(pattern: RegExp, timeoutMs?: number): Promise<ErrorWithContext>;
|
||||
}
|
||||
|
||||
export { MockFetchApi };
|
||||
// @public
|
||||
export type MockErrorApiOptions = {
|
||||
collect?: boolean;
|
||||
};
|
||||
|
||||
export { MockFetchApiOptions };
|
||||
// @public
|
||||
export class MockFeatureFlagsApi implements FeatureFlagsApi {
|
||||
constructor(options?: MockFeatureFlagsApiOptions);
|
||||
clearState(): void;
|
||||
// (undocumented)
|
||||
getRegisteredFlags(): FeatureFlag[];
|
||||
getState(): Record<string, FeatureFlagState>;
|
||||
// (undocumented)
|
||||
isActive(name: string): boolean;
|
||||
// (undocumented)
|
||||
registerFlag(flag: FeatureFlag): void;
|
||||
// (undocumented)
|
||||
save(options: FeatureFlagsSaveOptions): void;
|
||||
setState(states: Record<string, FeatureFlagState>): void;
|
||||
}
|
||||
|
||||
export { MockPermissionApi };
|
||||
// @public
|
||||
export interface MockFeatureFlagsApiOptions {
|
||||
initialStates?: Record<string, FeatureFlagState>;
|
||||
}
|
||||
|
||||
export { MockStorageApi };
|
||||
// @public
|
||||
export class MockFetchApi implements FetchApi {
|
||||
constructor(options?: MockFetchApiOptions);
|
||||
get fetch(): typeof fetch;
|
||||
}
|
||||
|
||||
export { MockStorageBucket };
|
||||
// @public
|
||||
export interface MockFetchApiOptions {
|
||||
baseImplementation?: undefined | 'none' | typeof fetch;
|
||||
injectIdentityAuth?:
|
||||
| undefined
|
||||
| {
|
||||
token: string;
|
||||
}
|
||||
| {
|
||||
identityApi: Pick<IdentityApi_2, 'getCredentials'>;
|
||||
};
|
||||
resolvePluginProtocol?:
|
||||
| undefined
|
||||
| {
|
||||
discoveryApi: Pick<DiscoveryApi_2, 'getBaseUrl'>;
|
||||
};
|
||||
}
|
||||
|
||||
// @public
|
||||
export class MockPermissionApi implements PermissionApi {
|
||||
constructor(
|
||||
requestHandler?: (
|
||||
request: EvaluatePermissionRequest,
|
||||
) => AuthorizeResult.ALLOW | AuthorizeResult.DENY,
|
||||
);
|
||||
// (undocumented)
|
||||
authorize(
|
||||
request: EvaluatePermissionRequest,
|
||||
): Promise<EvaluatePermissionResponse>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class MockStorageApi implements StorageApi {
|
||||
// (undocumented)
|
||||
static create(data?: JsonObject): MockStorageApi;
|
||||
// (undocumented)
|
||||
forBucket(name: string): StorageApi;
|
||||
// (undocumented)
|
||||
observe$<T extends JsonValue>(
|
||||
key: string,
|
||||
): Observable<StorageValueSnapshot<T>>;
|
||||
// (undocumented)
|
||||
remove(key: string): Promise<void>;
|
||||
// (undocumented)
|
||||
set<T>(key: string, data: T): Promise<void>;
|
||||
// (undocumented)
|
||||
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class MockTranslationApi implements TranslationApi {
|
||||
// (undocumented)
|
||||
static create(): MockTranslationApi;
|
||||
// (undocumented)
|
||||
getTranslation<
|
||||
TMessages extends {
|
||||
[key in string]: string;
|
||||
},
|
||||
>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages>;
|
||||
// (undocumented)
|
||||
translation$<
|
||||
TMessages extends {
|
||||
[key in string]: string;
|
||||
},
|
||||
>(): Observable<TranslationSnapshot<TMessages>>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type MockWithApiFactory<TApi> = TApi & {
|
||||
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
|
||||
};
|
||||
|
||||
export { registerMswTestHooks };
|
||||
|
||||
// @public
|
||||
export function renderInTestApp<TApiPairs extends any[] = any[]>(
|
||||
export function renderInTestApp<const TApiPairs extends any[] = any[]>(
|
||||
element: JSX.Element,
|
||||
options?: TestAppOptions<TApiPairs>,
|
||||
): RenderResult;
|
||||
|
||||
// @public
|
||||
export function renderTestApp<TApiPairs extends any[] = any[]>(
|
||||
options: RenderTestAppOptions<TApiPairs>,
|
||||
): RenderResult<testingLibraryDomTypesQueries, HTMLElement, HTMLElement>;
|
||||
export function renderTestApp<const TApiPairs extends any[] = any[]>(
|
||||
options?: RenderTestAppOptions<TApiPairs>,
|
||||
): RenderResult;
|
||||
|
||||
// @public
|
||||
export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
|
||||
@@ -152,37 +442,26 @@ export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
|
||||
};
|
||||
|
||||
// @public
|
||||
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
|
||||
export type TestApiPair<TApi> =
|
||||
| readonly [ApiRef<TApi>, TApi extends infer TImpl ? Partial<TImpl> : never]
|
||||
| MockWithApiFactory<TApi>;
|
||||
|
||||
// @public
|
||||
export const TestApiProvider: <T extends any[]>(
|
||||
props: TestApiProviderProps<T>,
|
||||
) => JSX_2.Element;
|
||||
export type TestApiPairs<TApiPairs> = {
|
||||
[TIndex in keyof TApiPairs]: TestApiPair<TApiPairs[TIndex]>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export function TestApiProvider<const TApiPairs extends any[]>(
|
||||
props: TestApiProviderProps<TApiPairs>,
|
||||
): JSX.Element;
|
||||
|
||||
// @public
|
||||
export type TestApiProviderProps<TApiPairs extends any[]> = {
|
||||
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
|
||||
apis: readonly [...TestApiPairs<TApiPairs>];
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type TestApiProviderPropsApiPair<TApi> = TApi extends infer TImpl
|
||||
? readonly [ApiRef<TApi>, Partial<TImpl>]
|
||||
: never;
|
||||
|
||||
// @public
|
||||
export type TestApiProviderPropsApiPairs<TApiPairs> = {
|
||||
[TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair<TApiPairs[TIndex]>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export class TestApiRegistry implements ApiHolder {
|
||||
static from<TApiPairs extends any[]>(
|
||||
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
|
||||
): TestApiRegistry;
|
||||
get<T>(api: ApiRef<T>): T | undefined;
|
||||
}
|
||||
|
||||
// @public
|
||||
export type TestAppOptions<TApiPairs extends any[] = any[]> = {
|
||||
mountedRoutes?: {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MockAlertApi } from './MockAlertApi';
|
||||
|
||||
describe('MockAlertApi', () => {
|
||||
it('should collect alerts', () => {
|
||||
const api = new MockAlertApi();
|
||||
|
||||
api.post({ message: 'Test alert 1' });
|
||||
api.post({ message: 'Test alert 2', severity: 'error' });
|
||||
api.post({
|
||||
message: 'Test alert 3',
|
||||
severity: 'warning',
|
||||
display: 'permanent',
|
||||
});
|
||||
|
||||
expect(api.getAlerts()).toHaveLength(3);
|
||||
expect(api.getAlerts()[0]).toMatchObject({ message: 'Test alert 1' });
|
||||
expect(api.getAlerts()[1]).toMatchObject({
|
||||
message: 'Test alert 2',
|
||||
severity: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear alerts', () => {
|
||||
const api = new MockAlertApi();
|
||||
|
||||
api.post({ message: 'Test alert' });
|
||||
expect(api.getAlerts()).toHaveLength(1);
|
||||
|
||||
api.clearAlerts();
|
||||
expect(api.getAlerts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should notify observers', async () => {
|
||||
const api = new MockAlertApi();
|
||||
const messages: string[] = [];
|
||||
|
||||
const collected = new Promise<void>(resolve => {
|
||||
api.alert$().subscribe({
|
||||
next: alert => {
|
||||
messages.push(alert.message);
|
||||
if (messages.length === 2) {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
api.post({ message: 'First' });
|
||||
api.post({ message: 'Second' });
|
||||
|
||||
await collected;
|
||||
expect(messages).toEqual(['First', 'Second']);
|
||||
});
|
||||
|
||||
it('should wait for matching alert', async () => {
|
||||
const api = new MockAlertApi();
|
||||
|
||||
setTimeout(() => {
|
||||
api.post({ message: 'Wrong alert' });
|
||||
api.post({ message: 'Right alert', severity: 'error' });
|
||||
}, 10);
|
||||
|
||||
const alert = await api.waitForAlert(
|
||||
a => a.message === 'Right alert',
|
||||
1000,
|
||||
);
|
||||
|
||||
expect(alert).toMatchObject({ message: 'Right alert', severity: 'error' });
|
||||
});
|
||||
|
||||
it('should timeout if alert never appears', async () => {
|
||||
const api = new MockAlertApi();
|
||||
|
||||
await expect(
|
||||
api.waitForAlert(a => a.message === 'Never posted', 100),
|
||||
).rejects.toThrow('Timed out waiting for alert');
|
||||
});
|
||||
|
||||
it('should resolve immediately if alert already exists', async () => {
|
||||
const api = new MockAlertApi();
|
||||
|
||||
api.post({ message: 'Already posted' });
|
||||
|
||||
const alert = await api.waitForAlert(
|
||||
a => a.message === 'Already posted',
|
||||
1000,
|
||||
);
|
||||
|
||||
expect(alert).toMatchObject({ message: 'Already posted' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AlertMessage } from '@backstage/frontend-plugin-api';
|
||||
import { AlertApi } from '@backstage/frontend-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link @backstage/frontend-plugin-api#AlertApi} for testing alert behavior.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
* ```ts
|
||||
* const alertApi = new MockAlertApi();
|
||||
* alertApi.post({ message: 'Test alert' });
|
||||
* expect(alertApi.getAlerts()).toHaveLength(1);
|
||||
* ```
|
||||
*/
|
||||
export class MockAlertApi implements AlertApi {
|
||||
private readonly alerts: AlertMessage[] = [];
|
||||
private readonly observers = new Set<(alert: AlertMessage) => void>();
|
||||
|
||||
post(alert: AlertMessage) {
|
||||
this.alerts.push(alert);
|
||||
this.observers.forEach(observer => observer(alert));
|
||||
}
|
||||
|
||||
alert$(): Observable<AlertMessage> {
|
||||
return new ObservableImpl(subscriber => {
|
||||
const observer = (alert: AlertMessage) => {
|
||||
subscriber.next(alert);
|
||||
};
|
||||
this.observers.add(observer);
|
||||
return () => {
|
||||
this.observers.delete(observer);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all alerts that have been posted.
|
||||
*/
|
||||
getAlerts(): AlertMessage[] {
|
||||
return this.alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all collected alerts.
|
||||
*/
|
||||
clearAlerts(): void {
|
||||
this.alerts.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an alert matching the given predicate.
|
||||
*
|
||||
* @param predicate - Function to test each alert
|
||||
* @param timeoutMs - Maximum time to wait in milliseconds
|
||||
* @returns Promise that resolves with the matching alert
|
||||
*/
|
||||
async waitForAlert(
|
||||
predicate: (alert: AlertMessage) => boolean,
|
||||
timeoutMs: number = 2000,
|
||||
): Promise<AlertMessage> {
|
||||
const existing = this.alerts.find(predicate);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const observers = this.observers;
|
||||
|
||||
return new Promise<AlertMessage>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
observers.delete(observer);
|
||||
reject(new Error('Timed out waiting for alert'));
|
||||
}, timeoutMs);
|
||||
|
||||
function observer(alert: AlertMessage) {
|
||||
if (predicate(alert)) {
|
||||
clearTimeout(timeoutId);
|
||||
observers.delete(observer);
|
||||
resolve(alert);
|
||||
}
|
||||
}
|
||||
|
||||
observers.add(observer);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockAlertApi } from './MockAlertApi';
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MockConfigApi } from './MockConfigApi';
|
||||
|
||||
describe('MockConfigApi', () => {
|
||||
it('is able to read some basic config', () => {
|
||||
const mock = new MockConfigApi({
|
||||
data: {
|
||||
app: {
|
||||
title: 'Hello',
|
||||
},
|
||||
x: 1,
|
||||
y: false,
|
||||
z: [{ a: 3 }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mock.getString('app.title')).toEqual('Hello');
|
||||
expect(mock.getNumber('x')).toEqual(1);
|
||||
expect(mock.getBoolean('y')).toEqual(false);
|
||||
expect(mock.getConfigArray('z')[0].getOptionalNumber('a')).toEqual(3);
|
||||
|
||||
expect(() => mock.getString('x')).toThrow(
|
||||
"Invalid type in config for key 'x' in 'mock-config', got number, wanted string",
|
||||
);
|
||||
expect(() => mock.getString('missing')).toThrow(
|
||||
"Missing required config value at 'missing'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Config, ConfigReader } from '@backstage/config';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
|
||||
/**
|
||||
* MockConfigApi is a thin wrapper around {@link @backstage/config#ConfigReader}
|
||||
* that can be used to mock configuration using a plain object.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
* ```tsx
|
||||
* const mockConfig = new MockConfigApi({
|
||||
* data: { app: { baseUrl: 'https://example.com' } },
|
||||
* });
|
||||
*
|
||||
* const rendered = await renderInTestApp(
|
||||
* <TestApiProvider apis={[[configApiRef, mockConfig]]}>
|
||||
* <MyTestedComponent />
|
||||
* </TestApiProvider>,
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export class MockConfigApi implements ConfigApi {
|
||||
private readonly config: ConfigReader;
|
||||
|
||||
// NOTE: not extending in order to avoid inheriting the static `.fromConfigs`
|
||||
constructor({ data }: { data: JsonObject }) {
|
||||
this.config = new ConfigReader(data);
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/config#Config.has} */
|
||||
has(key: string): boolean {
|
||||
return this.config.has(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.keys} */
|
||||
keys(): string[] {
|
||||
return this.config.keys();
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.get} */
|
||||
get<T = JsonValue>(key?: string): T {
|
||||
return this.config.get(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getOptional} */
|
||||
getOptional<T = JsonValue>(key?: string): T | undefined {
|
||||
return this.config.getOptional(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getConfig} */
|
||||
getConfig(key: string): Config {
|
||||
return this.config.getConfig(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getOptionalConfig} */
|
||||
getOptionalConfig(key: string): Config | undefined {
|
||||
return this.config.getOptionalConfig(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getConfigArray} */
|
||||
getConfigArray(key: string): Config[] {
|
||||
return this.config.getConfigArray(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getOptionalConfigArray} */
|
||||
getOptionalConfigArray(key: string): Config[] | undefined {
|
||||
return this.config.getOptionalConfigArray(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getNumber} */
|
||||
getNumber(key: string): number {
|
||||
return this.config.getNumber(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getOptionalNumber} */
|
||||
getOptionalNumber(key: string): number | undefined {
|
||||
return this.config.getOptionalNumber(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getBoolean} */
|
||||
getBoolean(key: string): boolean {
|
||||
return this.config.getBoolean(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getOptionalBoolean} */
|
||||
getOptionalBoolean(key: string): boolean | undefined {
|
||||
return this.config.getOptionalBoolean(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getString} */
|
||||
getString(key: string): string {
|
||||
return this.config.getString(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getOptionalString} */
|
||||
getOptionalString(key: string): string | undefined {
|
||||
return this.config.getOptionalString(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getStringArray} */
|
||||
getStringArray(key: string): string[] {
|
||||
return this.config.getStringArray(key);
|
||||
}
|
||||
/** {@inheritdoc @backstage/config#Config.getOptionalStringArray} */
|
||||
getOptionalStringArray(key: string): string[] | undefined {
|
||||
return this.config.getOptionalStringArray(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockConfigApi } from './MockConfigApi';
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MockErrorApi } from './MockErrorApi';
|
||||
|
||||
async function ifResolved<T>(promise: Promise<T>): Promise<T | 'not-yet'> {
|
||||
return Promise.race([promise, Promise.resolve<'not-yet'>('not-yet')]);
|
||||
}
|
||||
|
||||
describe('MockErrorApi', () => {
|
||||
it('should throw errors by default', () => {
|
||||
const api = new MockErrorApi();
|
||||
expect(() => api.post(new Error('NOPE'))).toThrow(
|
||||
'MockErrorApi received unexpected error, Error: NOPE',
|
||||
);
|
||||
});
|
||||
|
||||
it('should collect errors', () => {
|
||||
const api = new MockErrorApi({ collect: true });
|
||||
|
||||
api.post(new Error('e1'));
|
||||
api.post(new Error('e2'), { hidden: true });
|
||||
api.post(new Error('e3'));
|
||||
|
||||
expect(api.getErrors()).toEqual([
|
||||
{
|
||||
error: new Error('e1'),
|
||||
},
|
||||
{
|
||||
error: new Error('e2'),
|
||||
context: { hidden: true },
|
||||
},
|
||||
{
|
||||
error: new Error('e3'),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not emit values', async () => {
|
||||
const api = new MockErrorApi({ collect: true });
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
api.error$().subscribe({
|
||||
next({ error }) {
|
||||
reject(error);
|
||||
},
|
||||
error(error) {
|
||||
reject(error);
|
||||
},
|
||||
complete() {
|
||||
reject(new Error('observable was completed'));
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => resolve('timed-out'), 100);
|
||||
});
|
||||
|
||||
await expect(promise).resolves.toBe('timed-out');
|
||||
});
|
||||
|
||||
it('should wait for errors', async () => {
|
||||
const api = new MockErrorApi({ collect: true });
|
||||
|
||||
const wait1 = api.waitForError(/1/);
|
||||
const wait2 = api.waitForError(/2/);
|
||||
|
||||
await expect(ifResolved(wait1)).resolves.toBe('not-yet');
|
||||
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
|
||||
api.post(new Error('e0'));
|
||||
await expect(ifResolved(wait1)).resolves.toBe('not-yet');
|
||||
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
|
||||
api.post(new Error('e1'));
|
||||
await expect(ifResolved(wait1)).resolves.toEqual({
|
||||
error: new Error('e1'),
|
||||
});
|
||||
await expect(ifResolved(wait2)).resolves.toBe('not-yet');
|
||||
api.post(new Error('e2'), { hidden: true });
|
||||
await expect(ifResolved(wait2)).resolves.toEqual({
|
||||
error: new Error('e2'),
|
||||
context: { hidden: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should time out waiting for error', async () => {
|
||||
const api = new MockErrorApi({ collect: true });
|
||||
|
||||
await expect(api.waitForError(/1/, 1)).rejects.toThrow(
|
||||
'Timed out waiting for error',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ErrorApi,
|
||||
ErrorApiError,
|
||||
ErrorApiErrorContext,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
|
||||
/**
|
||||
* Constructor arguments for {@link MockErrorApi}
|
||||
* @public
|
||||
*/
|
||||
export type MockErrorApiOptions = {
|
||||
// Need to be true if getErrors is used in testing.
|
||||
collect?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* ErrorWithContext contains error and ErrorApiErrorContext
|
||||
* @public
|
||||
*/
|
||||
export type ErrorWithContext = {
|
||||
error: ErrorApiError;
|
||||
context?: ErrorApiErrorContext;
|
||||
};
|
||||
|
||||
type Waiter = {
|
||||
pattern: RegExp;
|
||||
resolve: (err: ErrorWithContext) => void;
|
||||
};
|
||||
|
||||
const nullObservable = {
|
||||
subscribe: () => ({ unsubscribe: () => {}, closed: true }),
|
||||
|
||||
[Symbol.observable]() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock implementation of the {@link core-plugin-api#ErrorApi} to be used in tests.
|
||||
* Includes withForError and getErrors methods for error testing.
|
||||
* @public
|
||||
*/
|
||||
export class MockErrorApi implements ErrorApi {
|
||||
private readonly errors = new Array<ErrorWithContext>();
|
||||
private readonly waiters = new Set<Waiter>();
|
||||
|
||||
constructor(private readonly options: MockErrorApiOptions = {}) {}
|
||||
|
||||
post(error: ErrorApiError, context?: ErrorApiErrorContext) {
|
||||
if (this.options.collect) {
|
||||
this.errors.push({ error, context });
|
||||
|
||||
for (const waiter of this.waiters) {
|
||||
if (waiter.pattern.test(error.message)) {
|
||||
this.waiters.delete(waiter);
|
||||
waiter.resolve({ error, context });
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`MockErrorApi received unexpected error, ${error}`);
|
||||
}
|
||||
|
||||
error$(): Observable<{
|
||||
error: ErrorApiError;
|
||||
context?: ErrorApiErrorContext;
|
||||
}> {
|
||||
return nullObservable;
|
||||
}
|
||||
|
||||
getErrors(): ErrorWithContext[] {
|
||||
return this.errors;
|
||||
}
|
||||
|
||||
waitForError(
|
||||
pattern: RegExp,
|
||||
timeoutMs: number = 2000,
|
||||
): Promise<ErrorWithContext> {
|
||||
return new Promise<ErrorWithContext>((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new Error('Timed out waiting for error'));
|
||||
}, timeoutMs);
|
||||
|
||||
this.waiters.add({ resolve, pattern });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockErrorApi } from './MockErrorApi';
|
||||
export type { MockErrorApiOptions, ErrorWithContext } from './MockErrorApi';
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
|
||||
import { MockFeatureFlagsApi } from './MockFeatureFlagsApi';
|
||||
|
||||
describe('MockFeatureFlagsApi', () => {
|
||||
it('should register flags', () => {
|
||||
const api = new MockFeatureFlagsApi();
|
||||
|
||||
api.registerFlag({
|
||||
name: 'test-flag-1',
|
||||
pluginId: 'test-plugin',
|
||||
description: 'Test flag 1',
|
||||
});
|
||||
|
||||
api.registerFlag({
|
||||
name: 'test-flag-2',
|
||||
pluginId: 'test-plugin',
|
||||
description: 'Test flag 2',
|
||||
});
|
||||
|
||||
expect(api.getRegisteredFlags()).toHaveLength(2);
|
||||
expect(api.getRegisteredFlags()[0].name).toBe('test-flag-1');
|
||||
});
|
||||
|
||||
it('should not register duplicate flags', () => {
|
||||
const api = new MockFeatureFlagsApi();
|
||||
|
||||
api.registerFlag({
|
||||
name: 'test-flag',
|
||||
pluginId: 'test-plugin',
|
||||
description: 'Test flag',
|
||||
});
|
||||
|
||||
api.registerFlag({
|
||||
name: 'test-flag',
|
||||
pluginId: 'test-plugin',
|
||||
description: 'Test flag duplicate',
|
||||
});
|
||||
|
||||
expect(api.getRegisteredFlags()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle feature flag states', () => {
|
||||
const api = new MockFeatureFlagsApi();
|
||||
|
||||
expect(api.isActive('test-flag')).toBe(false);
|
||||
|
||||
api.save({ states: { 'test-flag': FeatureFlagState.Active } });
|
||||
expect(api.isActive('test-flag')).toBe(true);
|
||||
|
||||
api.save({ states: { 'test-flag': FeatureFlagState.None } });
|
||||
expect(api.isActive('test-flag')).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with states', () => {
|
||||
const api = new MockFeatureFlagsApi({
|
||||
initialStates: {
|
||||
'flag-1': FeatureFlagState.Active,
|
||||
'flag-2': FeatureFlagState.None,
|
||||
},
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
expect(api.isActive('flag-2')).toBe(false);
|
||||
});
|
||||
|
||||
it('should save and replace states', () => {
|
||||
const api = new MockFeatureFlagsApi({
|
||||
initialStates: { 'flag-1': FeatureFlagState.Active },
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
|
||||
api.save({
|
||||
states: {
|
||||
'flag-2': FeatureFlagState.Active,
|
||||
},
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(false);
|
||||
expect(api.isActive('flag-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should save and merge states', () => {
|
||||
const api = new MockFeatureFlagsApi({
|
||||
initialStates: { 'flag-1': FeatureFlagState.Active },
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
|
||||
api.save({
|
||||
states: {
|
||||
'flag-2': FeatureFlagState.Active,
|
||||
},
|
||||
merge: true,
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
expect(api.isActive('flag-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should get and set state', () => {
|
||||
const api = new MockFeatureFlagsApi();
|
||||
|
||||
api.setState({
|
||||
'flag-1': FeatureFlagState.Active,
|
||||
'flag-2': FeatureFlagState.None,
|
||||
});
|
||||
|
||||
const state = api.getState();
|
||||
expect(state).toEqual({
|
||||
'flag-1': FeatureFlagState.Active,
|
||||
'flag-2': FeatureFlagState.None,
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear state', () => {
|
||||
const api = new MockFeatureFlagsApi({
|
||||
initialStates: { 'flag-1': FeatureFlagState.Active },
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
|
||||
api.clearState();
|
||||
expect(api.isActive('flag-1')).toBe(false);
|
||||
expect(api.getState()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
FeatureFlag,
|
||||
FeatureFlagsApi,
|
||||
FeatureFlagsSaveOptions,
|
||||
FeatureFlagState,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* Options for configuring {@link MockFeatureFlagsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MockFeatureFlagsApiOptions {
|
||||
/**
|
||||
* Initial feature flag states.
|
||||
*/
|
||||
initialStates?: Record<string, FeatureFlagState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi} for testing feature flag behavior.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
* ```ts
|
||||
* const api = new MockFeatureFlagsApi({
|
||||
* initialStates: { 'my-feature': FeatureFlagState.Active }
|
||||
* });
|
||||
* expect(api.isActive('my-feature')).toBe(true);
|
||||
* ```
|
||||
*/
|
||||
export class MockFeatureFlagsApi implements FeatureFlagsApi {
|
||||
private readonly registeredFlags: FeatureFlag[] = [];
|
||||
private readonly states: Map<string, FeatureFlagState>;
|
||||
|
||||
constructor(options?: MockFeatureFlagsApiOptions) {
|
||||
this.states = new Map(Object.entries(options?.initialStates ?? {}));
|
||||
}
|
||||
|
||||
registerFlag(flag: FeatureFlag): void {
|
||||
if (!this.registeredFlags.some(f => f.name === flag.name)) {
|
||||
this.registeredFlags.push(flag);
|
||||
}
|
||||
}
|
||||
|
||||
getRegisteredFlags(): FeatureFlag[] {
|
||||
return this.registeredFlags;
|
||||
}
|
||||
|
||||
isActive(name: string): boolean {
|
||||
return this.states.get(name) === FeatureFlagState.Active;
|
||||
}
|
||||
|
||||
save(options: FeatureFlagsSaveOptions): void {
|
||||
if (options.merge) {
|
||||
for (const [name, state] of Object.entries(options.states)) {
|
||||
this.states.set(name, state);
|
||||
}
|
||||
} else {
|
||||
this.states.clear();
|
||||
for (const [name, state] of Object.entries(options.states)) {
|
||||
this.states.set(name, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of all feature flags as a record.
|
||||
*/
|
||||
getState(): Record<string, FeatureFlagState> {
|
||||
return Object.fromEntries(this.states);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of multiple feature flags.
|
||||
*/
|
||||
setState(states: Record<string, FeatureFlagState>): void {
|
||||
for (const [name, state] of Object.entries(states)) {
|
||||
this.states.set(name, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all feature flag states.
|
||||
*/
|
||||
clearState(): void {
|
||||
this.states.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockFeatureFlagsApi } from './MockFeatureFlagsApi';
|
||||
export type { MockFeatureFlagsApiOptions } from './MockFeatureFlagsApi';
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { registerMswTestHooks } from '../../..';
|
||||
import { MockFetchApi } from './MockFetchApi';
|
||||
|
||||
describe('MockFetchApi', () => {
|
||||
const worker = setupServer();
|
||||
registerMswTestHooks(worker);
|
||||
|
||||
it('works with default constructor', async () => {
|
||||
worker.use(
|
||||
http.get('http://example.com/data.json', () =>
|
||||
HttpResponse.json({ a: 'foo' }, { status: 200 }),
|
||||
),
|
||||
);
|
||||
const m = new MockFetchApi();
|
||||
const response = await m.fetch('http://example.com/data.json');
|
||||
await expect(response.json()).resolves.toEqual({ a: 'foo' });
|
||||
});
|
||||
|
||||
describe('baseImplementation', () => {
|
||||
it('works with a mock implementation', async () => {
|
||||
const inner = jest.fn();
|
||||
const m = new MockFetchApi({ baseImplementation: inner });
|
||||
await m.fetch('http://example.com/data.json');
|
||||
expect(inner).toHaveBeenLastCalledWith('http://example.com/data.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePluginProtocol', () => {
|
||||
it('works', async () => {
|
||||
const inner = jest.fn();
|
||||
const m = new MockFetchApi({
|
||||
baseImplementation: inner,
|
||||
resolvePluginProtocol: {
|
||||
discoveryApi: {
|
||||
getBaseUrl: async id => `https://blah.com/api/${id}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
await m.fetch('plugin://the-plugin/a/data.json');
|
||||
expect(inner.mock.calls[0][0]).toBe(
|
||||
'https://blah.com/api/the-plugin/a/data.json',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectIdentityAuth', () => {
|
||||
it('works with token', async () => {
|
||||
const inner = jest.fn();
|
||||
const m = new MockFetchApi({
|
||||
baseImplementation: inner,
|
||||
injectIdentityAuth: { token: 'hello' },
|
||||
});
|
||||
await m.fetch('http://example.com/data.json');
|
||||
expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe(
|
||||
'Bearer hello',
|
||||
);
|
||||
});
|
||||
|
||||
it('works with identityApi', async () => {
|
||||
const inner = jest.fn();
|
||||
const m = new MockFetchApi({
|
||||
baseImplementation: inner,
|
||||
injectIdentityAuth: {
|
||||
identityApi: {
|
||||
async getCredentials() {
|
||||
return { token: 'hello2' };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await m.fetch('http://example.com/data.json');
|
||||
expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe(
|
||||
'Bearer hello2',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
createFetchApi,
|
||||
FetchMiddleware,
|
||||
FetchMiddlewares,
|
||||
} from '@backstage/core-app-api';
|
||||
import {
|
||||
DiscoveryApi,
|
||||
FetchApi,
|
||||
IdentityApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
/**
|
||||
* The options given when constructing a {@link MockFetchApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MockFetchApiOptions {
|
||||
/**
|
||||
* Define the underlying base `fetch` implementation.
|
||||
*
|
||||
* @defaultValue undefined
|
||||
* @remarks
|
||||
*
|
||||
* Leaving out this parameter or passing `undefined`, makes the API use the
|
||||
* global `fetch` implementation to make real network requests.
|
||||
*
|
||||
* `'none'` swallows all calls and makes no requests at all.
|
||||
*
|
||||
* You can also pass in any `fetch` compatible callback, such as a
|
||||
* `jest.fn()`, if you want to use a custom implementation or to just track
|
||||
* and assert on calls.
|
||||
*/
|
||||
baseImplementation?: undefined | 'none' | typeof fetch;
|
||||
|
||||
/**
|
||||
* Add translation from `plugin://` URLs to concrete http(s) URLs, basically
|
||||
* simulating what
|
||||
* {@link @backstage/core-app-api#FetchMiddlewares.resolvePluginProtocol}
|
||||
* does.
|
||||
*
|
||||
* @defaultValue undefined
|
||||
* @remarks
|
||||
*
|
||||
* Leaving out this parameter or passing `undefined`, disables plugin protocol
|
||||
* translation.
|
||||
*
|
||||
* To enable the feature, pass in a discovery API which is then used to
|
||||
* resolve the URLs.
|
||||
*/
|
||||
resolvePluginProtocol?:
|
||||
| undefined
|
||||
| { discoveryApi: Pick<DiscoveryApi, 'getBaseUrl'> };
|
||||
|
||||
/**
|
||||
* Add token based Authorization headers to requests, basically simulating
|
||||
* what {@link @backstage/core-app-api#FetchMiddlewares.injectIdentityAuth}
|
||||
* does.
|
||||
*
|
||||
* @defaultValue undefined
|
||||
* @remarks
|
||||
*
|
||||
* Leaving out this parameter or passing `undefined`, disables auth injection.
|
||||
*
|
||||
* To enable the feature, pass in either a static token or an identity API
|
||||
* which is queried on each request for a token.
|
||||
*/
|
||||
injectIdentityAuth?:
|
||||
| undefined
|
||||
| { token: string }
|
||||
| { identityApi: Pick<IdentityApi, 'getCredentials'> };
|
||||
}
|
||||
|
||||
/**
|
||||
* A test helper implementation of {@link @backstage/core-plugin-api#FetchApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MockFetchApi implements FetchApi {
|
||||
private readonly implementation: FetchApi;
|
||||
|
||||
/**
|
||||
* Creates a mock {@link @backstage/core-plugin-api#FetchApi}.
|
||||
*/
|
||||
constructor(options?: MockFetchApiOptions) {
|
||||
this.implementation = build(options);
|
||||
}
|
||||
|
||||
/** {@inheritdoc @backstage/core-plugin-api#FetchApi.fetch} */
|
||||
get fetch(): typeof fetch {
|
||||
return this.implementation.fetch;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Helpers
|
||||
//
|
||||
|
||||
function build(options?: MockFetchApiOptions): FetchApi {
|
||||
return createFetchApi({
|
||||
baseImplementation: baseImplementation(options),
|
||||
middleware: [
|
||||
resolvePluginProtocol(options),
|
||||
injectIdentityAuth(options),
|
||||
].filter((x): x is FetchMiddleware => Boolean(x)),
|
||||
});
|
||||
}
|
||||
|
||||
function baseImplementation(
|
||||
options: MockFetchApiOptions | undefined,
|
||||
): typeof fetch {
|
||||
const implementation = options?.baseImplementation;
|
||||
if (!implementation) {
|
||||
// Return a wrapper that evaluates global.fetch at call time, not construction time.
|
||||
// This allows MSW to patch global.fetch after MockFetchApi is constructed.
|
||||
return (input, init) => global.fetch(input, init);
|
||||
} else if (implementation === 'none') {
|
||||
return () => Promise.resolve(new Response());
|
||||
}
|
||||
return implementation;
|
||||
}
|
||||
|
||||
function resolvePluginProtocol(
|
||||
allOptions: MockFetchApiOptions | undefined,
|
||||
): FetchMiddleware | undefined {
|
||||
const options = allOptions?.resolvePluginProtocol;
|
||||
if (!options) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return FetchMiddlewares.resolvePluginProtocol({
|
||||
discoveryApi: options.discoveryApi,
|
||||
});
|
||||
}
|
||||
|
||||
function injectIdentityAuth(
|
||||
allOptions: MockFetchApiOptions | undefined,
|
||||
): FetchMiddleware | undefined {
|
||||
const options = allOptions?.injectIdentityAuth;
|
||||
if (!options) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const identityApi: Pick<IdentityApi, 'getCredentials'> =
|
||||
'token' in options
|
||||
? { getCredentials: async () => ({ token: options.token }) }
|
||||
: options.identityApi;
|
||||
|
||||
return FetchMiddlewares.injectIdentityAuth({
|
||||
identityApi: identityApi as IdentityApi,
|
||||
allowUrl: () => true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockFetchApi } from './MockFetchApi';
|
||||
export type { MockFetchApiOptions } from './MockFetchApi';
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ApiFactory } from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* Symbol used to mark mock API instances with their corresponding API factory.
|
||||
*
|
||||
* @ignore
|
||||
*/
|
||||
export const mockApiFactorySymbol = Symbol.for('@backstage/mock-api');
|
||||
|
||||
/**
|
||||
* Symbol used to mark mock API instances with their corresponding API factory.
|
||||
* This allows mock APIs to be passed directly to test utilities without
|
||||
* needing to explicitly provide the [apiRef, implementation] tuple.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type MockApiFactorySymbol = typeof mockApiFactorySymbol;
|
||||
|
||||
/**
|
||||
* Type for an API instance that has been marked as a mock API.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type MockWithApiFactory<TApi> = TApi & {
|
||||
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to attach mock API metadata to an instance.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function mockWithApiFactory<TApi, TImpl extends TApi = TApi>(
|
||||
apiRef: ApiRef<TApi>,
|
||||
implementation: TImpl,
|
||||
): TImpl & { [mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}> } {
|
||||
const marked = implementation as TImpl & {
|
||||
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
|
||||
};
|
||||
(marked as any)[mockApiFactorySymbol] = {
|
||||
api: apiRef,
|
||||
deps: {},
|
||||
factory: () => implementation,
|
||||
};
|
||||
return marked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches mock API factory metadata to an API instance, allowing it to be
|
||||
* passed directly to test utilities without needing to explicitly provide
|
||||
* the [apiRef, implementation] tuple.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
* ```ts
|
||||
* const catalogApi = attachMockApiFactory(
|
||||
* catalogApiRef,
|
||||
* new InMemoryCatalogClient()
|
||||
* );
|
||||
* // Can now be passed directly to TestApiProvider
|
||||
* <TestApiProvider apis={[catalogApi]}>
|
||||
* ```
|
||||
*/
|
||||
export function attachMockApiFactory<TApi, TImpl extends TApi = TApi>(
|
||||
apiRef: ApiRef<TApi>,
|
||||
implementation: TImpl,
|
||||
): TImpl & { [mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}> } {
|
||||
const marked = implementation as TImpl & {
|
||||
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
|
||||
};
|
||||
(marked as any)[mockApiFactorySymbol] = {
|
||||
api: apiRef,
|
||||
deps: {},
|
||||
factory: () => implementation,
|
||||
};
|
||||
return marked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the API factory from a mock API instance.
|
||||
* Returns undefined if the value is not a mock API instance.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function getMockApiFactory(
|
||||
value: unknown,
|
||||
): ApiFactory<any, any, {}> | undefined {
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
mockApiFactorySymbol in value &&
|
||||
typeof (value as any)[mockApiFactorySymbol] === 'object'
|
||||
) {
|
||||
return (value as any)[mockApiFactorySymbol];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthorizeResult,
|
||||
createPermission,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { MockPermissionApi } from './MockPermissionApi';
|
||||
|
||||
describe('MockPermissionApi', () => {
|
||||
it('returns ALLOW by default', async () => {
|
||||
const api = new MockPermissionApi();
|
||||
|
||||
await expect(
|
||||
api.authorize({
|
||||
permission: createPermission({ name: 'permission.1', attributes: {} }),
|
||||
}),
|
||||
).resolves.toEqual({ result: AuthorizeResult.ALLOW });
|
||||
});
|
||||
|
||||
it('allows passing a handler to customize the result', async () => {
|
||||
const api = new MockPermissionApi(request =>
|
||||
request.permission.name === 'permission.2'
|
||||
? AuthorizeResult.DENY
|
||||
: AuthorizeResult.ALLOW,
|
||||
);
|
||||
|
||||
await expect(
|
||||
api.authorize({
|
||||
permission: createPermission({ name: 'permission.2', attributes: {} }),
|
||||
}),
|
||||
).resolves.toEqual({ result: AuthorizeResult.DENY });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { PermissionApi } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
EvaluatePermissionResponse,
|
||||
EvaluatePermissionRequest,
|
||||
AuthorizeResult,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
|
||||
/**
|
||||
* Mock implementation of
|
||||
* {@link @backstage/plugin-permission-react#PermissionApi}. Supply a
|
||||
* requestHandler function to override the mock result returned for a given
|
||||
* request.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MockPermissionApi implements PermissionApi {
|
||||
constructor(
|
||||
private readonly requestHandler: (
|
||||
request: EvaluatePermissionRequest,
|
||||
) => AuthorizeResult.ALLOW | AuthorizeResult.DENY = () =>
|
||||
AuthorizeResult.ALLOW,
|
||||
) {}
|
||||
|
||||
async authorize(
|
||||
request: EvaluatePermissionRequest,
|
||||
): Promise<EvaluatePermissionResponse> {
|
||||
return { result: this.requestHandler(request) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockPermissionApi } from './MockPermissionApi';
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { StorageApi } from '@backstage/core-plugin-api';
|
||||
import { MockStorageApi } from './MockStorageApi';
|
||||
|
||||
describe('WebStorage Storage API', () => {
|
||||
const createMockStorage = (): StorageApi => {
|
||||
return MockStorageApi.create();
|
||||
};
|
||||
|
||||
it('should return undefined for values which are unset', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
expect(storage.snapshot('myfakekey').value).toBeUndefined();
|
||||
expect(storage.snapshot('myfakekey')).toEqual({
|
||||
key: 'myfakekey',
|
||||
presence: 'absent',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow the setting and snapshotting of the simple data structures', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
await storage.set('myfakekey', 'helloimastring');
|
||||
await storage.set('mysecondfakekey', 1234);
|
||||
await storage.set('mythirdfakekey', true);
|
||||
expect(storage.snapshot('myfakekey').value).toBe('helloimastring');
|
||||
expect(storage.snapshot('mysecondfakekey').value).toBe(1234);
|
||||
expect(storage.snapshot('mythirdfakekey').value).toBe(true);
|
||||
expect(storage.snapshot('myfakekey')).toEqual({
|
||||
key: 'myfakekey',
|
||||
presence: 'present',
|
||||
value: 'helloimastring',
|
||||
});
|
||||
expect(storage.snapshot('mysecondfakekey')).toEqual({
|
||||
key: 'mysecondfakekey',
|
||||
presence: 'present',
|
||||
value: 1234,
|
||||
});
|
||||
expect(storage.snapshot('mythirdfakekey')).toEqual({
|
||||
key: 'mythirdfakekey',
|
||||
presence: 'present',
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow setting of complex datastructures', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const mockData = {
|
||||
something: 'here',
|
||||
is: [{ super: { complex: [{ but: 'something', why: true }] } }],
|
||||
};
|
||||
|
||||
await storage.set('myfakekey', mockData);
|
||||
|
||||
expect(storage.snapshot('myfakekey').value).toEqual(mockData);
|
||||
expect(storage.snapshot('myfakekey')).toEqual({
|
||||
key: 'myfakekey',
|
||||
presence: 'present',
|
||||
value: mockData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should subscribe to key changes when setting a new value', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const wrongKeyNextHandler = jest.fn();
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
const mockData = { hello: 'im a great new value' };
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
storage.observe$<typeof mockData>('correctKey').subscribe({
|
||||
next: (...args) => {
|
||||
selectedKeyNextHandler(...args);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
|
||||
|
||||
storage.set('correctKey', mockData);
|
||||
});
|
||||
|
||||
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'correctKey',
|
||||
presence: 'present',
|
||||
value: mockData,
|
||||
});
|
||||
});
|
||||
|
||||
it('should subscribe to key changes when deleting a value', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const wrongKeyNextHandler = jest.fn();
|
||||
const selectedKeyNextHandler = jest.fn();
|
||||
const mockData = { hello: 'im a great new value' };
|
||||
|
||||
storage.set('correctKey', mockData);
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
storage.observe$('correctKey').subscribe({
|
||||
next: (...args) => {
|
||||
selectedKeyNextHandler(...args);
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
|
||||
storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler });
|
||||
|
||||
storage.remove('correctKey');
|
||||
});
|
||||
|
||||
expect(wrongKeyNextHandler).not.toHaveBeenCalled();
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1);
|
||||
expect(selectedKeyNextHandler).toHaveBeenCalledWith({
|
||||
key: 'correctKey',
|
||||
presence: 'absent',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should be able to create different buckets for different uses', async () => {
|
||||
const rootStorage = createMockStorage();
|
||||
|
||||
const firstStorage = rootStorage.forBucket('userSettings');
|
||||
const secondStorage = rootStorage.forBucket('profileSettings');
|
||||
const keyName = 'blobby';
|
||||
|
||||
await firstStorage.set(keyName, 'boop');
|
||||
await secondStorage.set(keyName, 'deerp');
|
||||
|
||||
expect(firstStorage.snapshot(keyName)).not.toBe(
|
||||
secondStorage.snapshot(keyName),
|
||||
);
|
||||
expect(firstStorage.snapshot(keyName).value).toBe('boop');
|
||||
expect(secondStorage.snapshot(keyName).value).toBe('deerp');
|
||||
expect(firstStorage.snapshot(keyName)).not.toEqual(
|
||||
secondStorage.snapshot(keyName),
|
||||
);
|
||||
expect(firstStorage.snapshot(keyName)).toEqual({
|
||||
key: keyName,
|
||||
presence: 'present',
|
||||
value: 'boop',
|
||||
});
|
||||
expect(secondStorage.snapshot(keyName)).toEqual({
|
||||
key: keyName,
|
||||
presence: 'present',
|
||||
value: 'deerp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not clash with other namespaces when creating buckets', async () => {
|
||||
const rootStorage = createMockStorage();
|
||||
|
||||
// when getting key test2 it will translate to /profile/something/deep/test2
|
||||
const firstStorage = rootStorage
|
||||
.forBucket('profile')
|
||||
.forBucket('something')
|
||||
.forBucket('deep');
|
||||
// when getting key deep/test2 it will translate to /profile/something/deep/test2
|
||||
const secondStorage = rootStorage.forBucket('profile/something');
|
||||
|
||||
await firstStorage.set('test2', { error: true });
|
||||
|
||||
expect(secondStorage.snapshot('deep/test2').value).toBe(undefined);
|
||||
expect(secondStorage.snapshot('deep/test2')).toMatchObject({
|
||||
presence: 'absent',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not reuse storage instances between different rootStorages', async () => {
|
||||
const rootStorage1 = createMockStorage();
|
||||
const rootStorage2 = createMockStorage();
|
||||
|
||||
const firstStorage = rootStorage1.forBucket('something');
|
||||
const secondStorage = rootStorage2.forBucket('something');
|
||||
|
||||
await firstStorage.set('test2', true);
|
||||
|
||||
expect(firstStorage.snapshot('test2').value).toBe(true);
|
||||
expect(secondStorage.snapshot('test2').value).toBe(undefined);
|
||||
expect(firstStorage.snapshot('test2')).toEqual({
|
||||
key: 'test2',
|
||||
presence: 'present',
|
||||
value: true,
|
||||
});
|
||||
expect(secondStorage.snapshot('test2')).toEqual({
|
||||
key: 'test2',
|
||||
presence: 'absent',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should freeze the snapshot value', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const data = { foo: 'bar', baz: [{ foo: 'bar' }] };
|
||||
storage.set('foo', data);
|
||||
|
||||
const snapshot = storage.snapshot<typeof data>('foo');
|
||||
expect(snapshot.value).not.toBe(data);
|
||||
|
||||
if (snapshot.presence !== 'present') {
|
||||
throw new Error('Invalid presence');
|
||||
}
|
||||
|
||||
expect(() => {
|
||||
snapshot.value.foo = 'buzz';
|
||||
}).toThrow(/Cannot assign to read only property/);
|
||||
expect(() => {
|
||||
snapshot.value.baz[0].foo = 'buzz';
|
||||
}).toThrow(/Cannot assign to read only property/);
|
||||
expect(() => {
|
||||
snapshot.value.baz.push({ foo: 'buzz' });
|
||||
}).toThrow(/Cannot add property 1, object is not extensible/);
|
||||
});
|
||||
|
||||
it('should freeze observed values', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
const snapshotPromise = new Promise<any>(resolve => {
|
||||
storage.observe$('test').subscribe({
|
||||
next: resolve,
|
||||
});
|
||||
});
|
||||
|
||||
storage.set('test', {
|
||||
foo: {
|
||||
bar: 'baz',
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await snapshotPromise;
|
||||
expect(snapshot.presence).toBe('present');
|
||||
expect(() => {
|
||||
snapshot.value!.foo.bar = 'qux';
|
||||
}).toThrow(/Cannot assign to read only property 'bar' of object/);
|
||||
});
|
||||
|
||||
it('should JSON serialize stored values', async () => {
|
||||
const storage = createMockStorage();
|
||||
|
||||
storage.set<any>('test', {
|
||||
foo: {
|
||||
toJSON() {
|
||||
return {
|
||||
bar: 'baz',
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(storage.snapshot('test')).toMatchObject({
|
||||
presence: 'present',
|
||||
value: {
|
||||
foo: {
|
||||
bar: 'baz',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { StorageApi, StorageValueSnapshot } from '@backstage/core-plugin-api';
|
||||
import { JsonObject, JsonValue, Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
/**
|
||||
* Mock implementation of the {@link core-plugin-api#StorageApi} to be used in tests
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MockStorageApi implements StorageApi {
|
||||
private readonly namespace: string;
|
||||
private readonly data: JsonObject;
|
||||
private readonly bucketStorageApis: Map<string, MockStorageApi>;
|
||||
|
||||
private constructor(
|
||||
namespace: string,
|
||||
bucketStorageApis: Map<string, MockStorageApi>,
|
||||
data?: JsonObject,
|
||||
) {
|
||||
this.namespace = namespace;
|
||||
this.bucketStorageApis = bucketStorageApis;
|
||||
this.data = { ...data };
|
||||
}
|
||||
|
||||
static create(data?: JsonObject) {
|
||||
// Translate a nested data object structure into a flat object with keys
|
||||
// like `/a/b` with their corresponding leaf values
|
||||
const keyValues: { [key: string]: any } = {};
|
||||
function put(value: { [key: string]: any }, namespace: string) {
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (typeof val === 'object' && val !== null) {
|
||||
put(val, `${namespace}/${key}`);
|
||||
} else {
|
||||
const namespacedKey = `${namespace}/${key.replace(/^\//, '')}`;
|
||||
keyValues[namespacedKey] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
put(data ?? {}, '');
|
||||
return new MockStorageApi('', new Map(), keyValues);
|
||||
}
|
||||
|
||||
forBucket(name: string): StorageApi {
|
||||
if (!this.bucketStorageApis.has(name)) {
|
||||
this.bucketStorageApis.set(
|
||||
name,
|
||||
new MockStorageApi(
|
||||
`${this.namespace}/${name}`,
|
||||
this.bucketStorageApis,
|
||||
this.data,
|
||||
),
|
||||
);
|
||||
}
|
||||
return this.bucketStorageApis.get(name)!;
|
||||
}
|
||||
|
||||
snapshot<T extends JsonValue>(key: string): StorageValueSnapshot<T> {
|
||||
if (this.data.hasOwnProperty(this.getKeyName(key))) {
|
||||
const data = this.data[this.getKeyName(key)];
|
||||
return {
|
||||
key,
|
||||
presence: 'present',
|
||||
value: data as T,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key,
|
||||
presence: 'absent',
|
||||
value: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async set<T>(key: string, data: T): Promise<void> {
|
||||
const serialized = JSON.parse(JSON.stringify(data), (_key, value) => {
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
Object.freeze(value);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
this.data[this.getKeyName(key)] = serialized;
|
||||
this.notifyChanges({
|
||||
key,
|
||||
presence: 'present',
|
||||
value: serialized,
|
||||
});
|
||||
}
|
||||
|
||||
async remove(key: string): Promise<void> {
|
||||
delete this.data[this.getKeyName(key)];
|
||||
this.notifyChanges({
|
||||
key,
|
||||
presence: 'absent',
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
observe$<T extends JsonValue>(
|
||||
key: string,
|
||||
): Observable<StorageValueSnapshot<T>> {
|
||||
return this.observable.filter(({ key: messageKey }) => messageKey === key);
|
||||
}
|
||||
|
||||
private getKeyName(key: string) {
|
||||
return `${this.namespace}/${encodeURIComponent(key)}`;
|
||||
}
|
||||
|
||||
private notifyChanges<T extends JsonValue>(message: StorageValueSnapshot<T>) {
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription.next(message);
|
||||
}
|
||||
}
|
||||
|
||||
private subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<StorageValueSnapshot<JsonValue>>
|
||||
>();
|
||||
|
||||
private readonly observable = new ObservableImpl<
|
||||
StorageValueSnapshot<JsonValue>
|
||||
>(subscriber => {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockStorageApi } from './MockStorageApi';
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ApiProvider } from '../../../core-app-api/src/apis/system';
|
||||
import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
getMockApiFactory,
|
||||
type MockWithApiFactory,
|
||||
} from './MockWithApiFactory';
|
||||
|
||||
/**
|
||||
* Represents a single API implementation, either as a tuple of the reference and the implementation, or a mock with an embedded factory.
|
||||
* @public
|
||||
*/
|
||||
export type TestApiPair<TApi> =
|
||||
| readonly [ApiRef<TApi>, TApi extends infer TImpl ? Partial<TImpl> : never]
|
||||
| MockWithApiFactory<TApi>;
|
||||
|
||||
/**
|
||||
* Represents an array of mock API implementation.
|
||||
* @public
|
||||
*/
|
||||
export type TestApiPairs<TApiPairs> = {
|
||||
[TIndex in keyof TApiPairs]: TestApiPair<TApiPairs[TIndex]>;
|
||||
};
|
||||
|
||||
/** @internal */
|
||||
export function resolveTestApiEntries<const TApiPairs extends any[]>(
|
||||
apis: readonly [...TestApiPairs<TApiPairs>],
|
||||
): ApiHolder {
|
||||
const apiMap = new Map<string, unknown>();
|
||||
|
||||
for (const entry of apis) {
|
||||
const mockFactory = getMockApiFactory(entry);
|
||||
if (mockFactory) {
|
||||
apiMap.set(mockFactory.api.id, mockFactory.factory({}));
|
||||
} else {
|
||||
const [apiRef, impl] = entry as readonly [ApiRef<any>, any];
|
||||
apiMap.set(apiRef.id, impl);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get: <T,>(ref: ApiRef<T>) => apiMap.get(ref.id) as T | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties for the {@link TestApiProvider} component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TestApiProviderProps<TApiPairs extends any[]> = {
|
||||
apis: readonly [...TestApiPairs<TApiPairs>];
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `TestApiProvider` is a Utility API context provider for standalone rendering
|
||||
* scenarios where you're not using `renderInTestApp` or other test utilities.
|
||||
*
|
||||
* It lets you provide any number of API implementations, without necessarily
|
||||
* having to fully implement each of the APIs.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* For most test scenarios, prefer using the `apis` option in `renderInTestApp` or
|
||||
* `createExtensionTester` instead of wrapping components with `TestApiProvider`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { render } from '\@testing-library/react';
|
||||
* import { myCustomApiRef } from '../apis';
|
||||
* import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils';
|
||||
*
|
||||
* // Mock custom APIs with tuple syntax
|
||||
* const myCustomApiMock = { myMethod: jest.fn() };
|
||||
* render(
|
||||
* <TestApiProvider
|
||||
* apis={[
|
||||
* [myCustomApiRef, myCustomApiMock]
|
||||
* ]}
|
||||
* >
|
||||
* <MyComponent />
|
||||
* </TestApiProvider>
|
||||
* );
|
||||
*
|
||||
* // Use with built-in mock APIs (no tuples needed)
|
||||
* render(
|
||||
* <TestApiProvider
|
||||
* apis={[
|
||||
* mockApis.identity({ userEntityRef: 'user:default/guest' }),
|
||||
* mockApis.alert(),
|
||||
* ]}
|
||||
* >
|
||||
* <MyComponent />
|
||||
* </TestApiProvider>
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function TestApiProvider<const TApiPairs extends any[]>(
|
||||
props: TestApiProviderProps<TApiPairs>,
|
||||
): JSX.Element {
|
||||
return (
|
||||
<ApiProvider
|
||||
apis={resolveTestApiEntries(props.apis)}
|
||||
children={props.children}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { MockTranslationApi } from './MockTranslationApi';
|
||||
|
||||
describe('MockTranslationApi', () => {
|
||||
function snapshotWithMessages<
|
||||
const TMessages extends { [key in string]: string },
|
||||
>(messages: TMessages) {
|
||||
const translationApi = MockTranslationApi.create();
|
||||
const ref = createTranslationRef({
|
||||
id: 'test',
|
||||
messages,
|
||||
});
|
||||
const snapshot = translationApi.getTranslation(ref);
|
||||
if (!snapshot.ready) {
|
||||
throw new Error('Translation snapshot is not ready');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
it('should format plain messages', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo',
|
||||
bar: 'Bar',
|
||||
baz: 'Baz',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo')).toBe('Foo');
|
||||
expect(snapshot.t('bar')).toBe('Bar');
|
||||
expect(snapshot.t('baz')).toBe('Baz');
|
||||
});
|
||||
|
||||
it('should support interpolation', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
shallow: 'Foo {{ bar }}',
|
||||
multiple: 'Foo {{ bar }} {{ baz }}',
|
||||
deep: 'Foo {{ bar.baz }}',
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
expect(snapshot.t('shallow')).toBe('Foo {{ bar }}');
|
||||
expect(snapshot.t('shallow', { bar: 'Bar' })).toBe('Foo Bar');
|
||||
|
||||
// @ts-expect-error
|
||||
expect(snapshot.t('multiple')).toBe('Foo {{ bar }} {{ baz }}');
|
||||
// @ts-expect-error
|
||||
expect(snapshot.t('multiple', { bar: 'Bar' })).toBe('Foo Bar {{ baz }}');
|
||||
expect(snapshot.t('multiple', { bar: 'Bar', baz: 'Baz' })).toBe(
|
||||
'Foo Bar Baz',
|
||||
);
|
||||
|
||||
// @ts-expect-error
|
||||
expect(snapshot.t('deep')).toBe('Foo {{ bar.baz }}');
|
||||
expect(snapshot.t('deep', { bar: { baz: 'Baz' } })).toBe('Foo Baz');
|
||||
});
|
||||
|
||||
// Escaping isn't as useful in React, since we don't need to escape HTML in strings
|
||||
it('should not escape by default', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo {{ foo }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo', { foo: '<div>' })).toBe('Foo <div>');
|
||||
expect(
|
||||
snapshot.t('foo', {
|
||||
foo: '<div>',
|
||||
interpolation: { escapeValue: true },
|
||||
}),
|
||||
).toBe('Foo <div>');
|
||||
});
|
||||
|
||||
it('should support nesting', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
foo: 'Foo $t(bar) $t(baz)',
|
||||
bar: 'Nested',
|
||||
baz: 'Baz {{ qux }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep');
|
||||
});
|
||||
|
||||
it('should support jsx interpolation', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
empty: 'derp',
|
||||
jsx: '={{ x }}',
|
||||
jsxNested: '={{ x.y.z }}',
|
||||
jsxDeep: '<$t(jsx)>',
|
||||
});
|
||||
|
||||
expect(snapshot.t('jsx', { x: <h1>hello</h1> })).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
=
|
||||
<h1>
|
||||
hello
|
||||
</h1>
|
||||
</React.Fragment>
|
||||
`);
|
||||
expect(snapshot.t('jsx', { replace: { x: <h1>hello</h1> } }))
|
||||
.toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
=
|
||||
<h1>
|
||||
hello
|
||||
</h1>
|
||||
</React.Fragment>
|
||||
`);
|
||||
expect(
|
||||
snapshot.t('jsxNested', { replace: { x: { y: { z: <h1>hello</h1> } } } }),
|
||||
).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
=
|
||||
<h1>
|
||||
hello
|
||||
</h1>
|
||||
</React.Fragment>
|
||||
`);
|
||||
expect(snapshot.t('jsxDeep', { x: <h1>hello</h1> })).toMatchInlineSnapshot(`
|
||||
<React.Fragment>
|
||||
<=
|
||||
<h1>
|
||||
hello
|
||||
</h1>
|
||||
>
|
||||
</React.Fragment>
|
||||
`);
|
||||
});
|
||||
|
||||
it('should support formatting', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
plain: '= {{ x }}',
|
||||
number: '= {{ x, number }}',
|
||||
numberFixed: '= {{ x, number(minimumFractionDigits: 2) }}',
|
||||
relativeTime: '= {{ x, relativeTime }}',
|
||||
relativeSeconds: '= {{ x, relativeTime(second) }}',
|
||||
relativeSecondsShort:
|
||||
'= {{ x, relativeTime(range: second; style: short) }}',
|
||||
list: '= {{ x, list }}',
|
||||
});
|
||||
|
||||
expect(snapshot.t('plain', { x: '5' })).toBe('= 5');
|
||||
expect(snapshot.t('number', { x: 5 })).toBe('= 5');
|
||||
expect(
|
||||
snapshot.t('number', {
|
||||
x: 5,
|
||||
formatParams: { x: { minimumFractionDigits: 1 } },
|
||||
}),
|
||||
).toBe('= 5.0');
|
||||
expect(snapshot.t('numberFixed', { x: 5 })).toBe('= 5.00');
|
||||
expect(
|
||||
snapshot.t('numberFixed', {
|
||||
x: 5,
|
||||
formatParams: { x: { minimumFractionDigits: 3 } },
|
||||
}),
|
||||
).toBe('= 5.000');
|
||||
expect(snapshot.t('relativeTime', { x: 3 })).toBe('= in 3 days');
|
||||
expect(snapshot.t('relativeTime', { x: -3 })).toBe('= 3 days ago');
|
||||
expect(
|
||||
snapshot.t('relativeTime', {
|
||||
x: 15,
|
||||
formatParams: { x: { range: 'weeks' } },
|
||||
}),
|
||||
).toBe('= in 15 weeks');
|
||||
expect(
|
||||
snapshot.t('relativeTime', {
|
||||
x: 15,
|
||||
formatParams: { x: { range: 'weeks', style: 'short' } },
|
||||
}),
|
||||
).toBe('= in 15 wk.');
|
||||
expect(snapshot.t('relativeSeconds', { x: 1 })).toBe('= in 1 second');
|
||||
expect(snapshot.t('relativeSeconds', { x: 2 })).toBe('= in 2 seconds');
|
||||
expect(snapshot.t('relativeSeconds', { x: -3 })).toBe('= 3 seconds ago');
|
||||
expect(snapshot.t('relativeSeconds', { x: 0 })).toBe('= in 0 seconds');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 1 })).toBe('= in 1 sec.');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2 sec.');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3 sec. ago');
|
||||
expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0 sec.');
|
||||
expect(snapshot.t('list', { x: ['a'] })).toBe('= a');
|
||||
expect(snapshot.t('list', { x: ['a', 'b'] })).toBe('= a and b');
|
||||
expect(snapshot.t('list', { x: ['a', 'b', 'c'] })).toBe('= a, b, and c');
|
||||
});
|
||||
|
||||
it('should support plurals', () => {
|
||||
const snapshot = snapshotWithMessages({
|
||||
derp_one: 'derp',
|
||||
derp_other: 'derps',
|
||||
derpWithCount_one: '{{ count }} derp',
|
||||
derpWithCount_other: '{{ count }} derps',
|
||||
});
|
||||
|
||||
expect(snapshot.t('derp', { count: 1 })).toBe('derp');
|
||||
expect(snapshot.t('derp', { count: 2 })).toBe('derps');
|
||||
expect(snapshot.t('derp', { count: 0 })).toBe('derps');
|
||||
expect(snapshot.t('derpWithCount', { count: 1 })).toBe('1 derp');
|
||||
expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps');
|
||||
expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
TranslationApi,
|
||||
TranslationRef,
|
||||
TranslationSnapshot,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { createInstance as createI18n, type i18n as I18n } from 'i18next';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
import { Observable } from '@backstage/types';
|
||||
// Internal import to avoid code duplication, this will lead to duplication in build output
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { toInternalTranslationRef } from '../../../../frontend-plugin-api/src/translation/TranslationRef';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { JsxInterpolator } from '../../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi';
|
||||
|
||||
const DEFAULT_LANGUAGE = 'en';
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class MockTranslationApi implements TranslationApi {
|
||||
static create() {
|
||||
const i18n = createI18n({
|
||||
fallbackLng: DEFAULT_LANGUAGE,
|
||||
supportedLngs: [DEFAULT_LANGUAGE],
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
// Used for the JsxInterpolator format hook
|
||||
alwaysFormat: true,
|
||||
},
|
||||
ns: [],
|
||||
defaultNS: false,
|
||||
fallbackNS: false,
|
||||
|
||||
// Disable resource loading on init, meaning i18n will be ready to use immediately
|
||||
initImmediate: false,
|
||||
});
|
||||
|
||||
i18n.init();
|
||||
if (!i18n.isInitialized) {
|
||||
throw new Error('i18next was unexpectedly not initialized');
|
||||
}
|
||||
|
||||
const interpolator = JsxInterpolator.fromI18n(i18n);
|
||||
|
||||
return new MockTranslationApi(i18n, interpolator);
|
||||
}
|
||||
|
||||
readonly #i18n: I18n;
|
||||
readonly #interpolator: JsxInterpolator;
|
||||
readonly #registeredRefs = new Set<string>();
|
||||
|
||||
private constructor(i18n: I18n, interpolator: JsxInterpolator) {
|
||||
this.#i18n = i18n;
|
||||
this.#interpolator = interpolator;
|
||||
}
|
||||
|
||||
getTranslation<TMessages extends { [key in string]: string }>(
|
||||
translationRef: TranslationRef<string, TMessages>,
|
||||
): TranslationSnapshot<TMessages> {
|
||||
const internalRef = toInternalTranslationRef(translationRef);
|
||||
|
||||
if (!this.#registeredRefs.has(internalRef.id)) {
|
||||
this.#registeredRefs.add(internalRef.id);
|
||||
this.#i18n.addResourceBundle(
|
||||
DEFAULT_LANGUAGE,
|
||||
internalRef.id,
|
||||
internalRef.getDefaultMessages(),
|
||||
false, // do not merge
|
||||
true, // overwrite existing
|
||||
);
|
||||
}
|
||||
|
||||
const t = this.#interpolator.wrapT<TMessages>(
|
||||
this.#i18n.getFixedT(null, internalRef.id),
|
||||
);
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
t,
|
||||
};
|
||||
}
|
||||
|
||||
translation$<TMessages extends { [key in string]: string }>(): Observable<
|
||||
TranslationSnapshot<TMessages>
|
||||
> {
|
||||
// No need to implement, getTranslation will always return a ready snapshot
|
||||
return new ObservableImpl<TranslationSnapshot<TMessages>>(_subscriber => {
|
||||
return () => {};
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
-8
@@ -14,11 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
TestApiProvider,
|
||||
TestApiRegistry,
|
||||
type TestApiProviderPropsApiPair,
|
||||
type TestApiProviderPropsApiPairs,
|
||||
type TestApiPairs,
|
||||
} from './TestApiProvider';
|
||||
export type { TestApiProviderProps } from './TestApiProvider';
|
||||
export { MockTranslationApi } from './MockTranslationApi';
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { createApiMock } from './createApiMock';
|
||||
import { getMockApiFactory } from './MockWithApiFactory';
|
||||
|
||||
describe('createApiMock', () => {
|
||||
type TestApi = {
|
||||
greet(name: string): string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const testApiRef = createApiRef<TestApi>({ id: 'test.create-mock' });
|
||||
|
||||
it('returns a factory function that produces jest mocks', () => {
|
||||
const mock = createApiMock(testApiRef, () => ({
|
||||
greet: jest.fn(),
|
||||
count: 0 as any,
|
||||
}));
|
||||
|
||||
const api = mock();
|
||||
api.greet('world');
|
||||
expect(api.greet).toHaveBeenCalledTimes(1);
|
||||
expect(api.greet).toHaveBeenCalledWith('world');
|
||||
});
|
||||
|
||||
it('applies partial implementations via mockImplementation', () => {
|
||||
const mock = createApiMock(testApiRef, () => ({
|
||||
greet: jest.fn(),
|
||||
count: 0 as any,
|
||||
}));
|
||||
|
||||
const api = mock({ greet: (name: string) => `Hello ${name}!` });
|
||||
expect(api.greet('world')).toBe('Hello world!');
|
||||
expect(api.greet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves non-function partial values', () => {
|
||||
const mock = createApiMock(testApiRef, () => ({
|
||||
greet: jest.fn(),
|
||||
count: 0 as any,
|
||||
}));
|
||||
|
||||
const api = mock({ count: 42 });
|
||||
expect(api.count).toBe(42);
|
||||
});
|
||||
|
||||
it('attaches a mock API factory via the symbol', () => {
|
||||
const mock = createApiMock(testApiRef, () => ({
|
||||
greet: jest.fn(),
|
||||
count: 0 as any,
|
||||
}));
|
||||
|
||||
const api = mock();
|
||||
const factory = getMockApiFactory(api);
|
||||
expect(factory).toBeDefined();
|
||||
expect(factory!.api).toBe(testApiRef);
|
||||
});
|
||||
|
||||
it('creates fresh mocks on each call', () => {
|
||||
const mock = createApiMock(testApiRef, () => ({
|
||||
greet: jest.fn(),
|
||||
count: 0 as any,
|
||||
}));
|
||||
|
||||
const api1 = mock();
|
||||
const api2 = mock();
|
||||
api1.greet('a');
|
||||
expect(api1.greet).toHaveBeenCalledTimes(1);
|
||||
expect(api2.greet).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiFactory, type ApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { mockApiFactorySymbol } from './MockWithApiFactory';
|
||||
|
||||
/**
|
||||
* Represents a mocked version of an API, where you automatically have access to
|
||||
* the mocked versions of all of its methods along with a factory that returns
|
||||
* that same mock.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ApiMock<TApi> = {
|
||||
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
|
||||
} & {
|
||||
[Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return
|
||||
? TApi[Key] & jest.MockInstance<Return, Args>
|
||||
: TApi[Key];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a standardized Backstage Utility API mockfactory function for
|
||||
* producing mock API instances.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* Each method in the mock factory is a `jest.fn()`, and you can optionally pass
|
||||
* partial implementations when calling the returned function. No type
|
||||
* parameters should be provided to this function, they will be inferred from
|
||||
* the provided API reference.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createApiMock } from '@backstage/frontend-test-utils';
|
||||
* import { myApiRef } from '../apis';
|
||||
*
|
||||
* // Set up the mock factory
|
||||
* const mock = createApiMock(myApiRef, () => ({
|
||||
* greet: jest.fn(),
|
||||
* }));
|
||||
*
|
||||
* // Create a mock with default behavior
|
||||
* const api = mock();
|
||||
*
|
||||
* // Or with a partial implementation
|
||||
* const api = mock({ greet: async () => 'Hello!' });
|
||||
* expect(api.greet).toHaveBeenCalledTimes(1);
|
||||
* ```
|
||||
*/
|
||||
export function createApiMock<TApi>(
|
||||
apiRef: ApiRef<TApi>,
|
||||
mockFactory: () => jest.Mocked<TApi>,
|
||||
): (partialImpl?: Partial<TApi>) => ApiMock<TApi> {
|
||||
return partialImpl => {
|
||||
const mock = mockFactory();
|
||||
if (partialImpl) {
|
||||
for (const [key, impl] of Object.entries(partialImpl)) {
|
||||
if (typeof impl === 'function') {
|
||||
(mock as any)[key].mockImplementation(impl);
|
||||
} else {
|
||||
(mock as any)[key] = impl;
|
||||
}
|
||||
}
|
||||
}
|
||||
(mock as any)[mockApiFactorySymbol] = {
|
||||
api: apiRef,
|
||||
deps: {},
|
||||
factory: () => mock,
|
||||
};
|
||||
return mock as unknown as ApiMock<TApi>;
|
||||
};
|
||||
}
|
||||
@@ -14,18 +14,73 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { mockApis } from './mockApis';
|
||||
export { createApiMock, type ApiMock } from './createApiMock';
|
||||
export {
|
||||
MockConfigApi,
|
||||
type ErrorWithContext,
|
||||
MockErrorApi,
|
||||
type MockErrorApiOptions,
|
||||
MockFetchApi,
|
||||
type MockFetchApiOptions,
|
||||
MockPermissionApi,
|
||||
MockStorageApi,
|
||||
type MockStorageBucket,
|
||||
mockApis,
|
||||
type ApiMock,
|
||||
} from '@backstage/test-utils';
|
||||
type MockApiFactorySymbol,
|
||||
type MockWithApiFactory,
|
||||
attachMockApiFactory,
|
||||
} from './MockWithApiFactory';
|
||||
export {
|
||||
TestApiProvider,
|
||||
type TestApiProviderProps,
|
||||
type TestApiPair,
|
||||
type TestApiPairs,
|
||||
} from './TestApiProvider';
|
||||
|
||||
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
|
||||
/**
|
||||
* Mock API classes are exported as types only to prevent direct instantiation.
|
||||
* Always use the `mockApis` namespace to create mock instances (e.g., `mockApis.alert()`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockAlertApi } from './AlertApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockAnalyticsApi } from './AnalyticsApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockConfigApi } from './ConfigApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type {
|
||||
MockErrorApi,
|
||||
MockErrorApiOptions,
|
||||
ErrorWithContext,
|
||||
} from './ErrorApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockFetchApi, MockFetchApiOptions } from './FetchApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type {
|
||||
MockFeatureFlagsApi,
|
||||
MockFeatureFlagsApiOptions,
|
||||
} from './FeatureFlagsApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockPermissionApi } from './PermissionApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockStorageApi } from './StorageApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockTranslationApi } from './TranslationApi';
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
|
||||
import { mockApis } from './mockApis';
|
||||
|
||||
describe('mockApis', () => {
|
||||
describe('alert', () => {
|
||||
it('can create an instance', () => {
|
||||
const alert = mockApis.alert();
|
||||
alert.post({ message: 'test alert' });
|
||||
expect(alert.getAlerts()).toHaveLength(1);
|
||||
expect(alert.getAlerts()[0]).toMatchObject({ message: 'test alert' });
|
||||
});
|
||||
|
||||
it('can clear alerts', () => {
|
||||
const alert = mockApis.alert();
|
||||
alert.post({ message: 'test' });
|
||||
expect(alert.getAlerts()).toHaveLength(1);
|
||||
alert.clearAlerts();
|
||||
expect(alert.getAlerts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can create a mock and make assertions on it', () => {
|
||||
const alert = mockApis.alert.mock({
|
||||
post: jest.fn(msg => {
|
||||
expect(msg).toMatchObject({ message: 'test' });
|
||||
}),
|
||||
});
|
||||
alert.post({ message: 'test' });
|
||||
expect(alert.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('featureFlags', () => {
|
||||
it('can create an instance', () => {
|
||||
const featureFlags = mockApis.featureFlags({
|
||||
initialStates: { 'test-flag': FeatureFlagState.Active },
|
||||
});
|
||||
expect(featureFlags.isActive('test-flag')).toBe(true);
|
||||
expect(featureFlags.isActive('other-flag')).toBe(false);
|
||||
});
|
||||
|
||||
it('can save and merge state', () => {
|
||||
const featureFlags = mockApis.featureFlags({
|
||||
initialStates: { 'flag-1': FeatureFlagState.Active },
|
||||
});
|
||||
|
||||
featureFlags.save({
|
||||
states: { 'flag-2': FeatureFlagState.Active },
|
||||
merge: true,
|
||||
});
|
||||
|
||||
expect(featureFlags.isActive('flag-1')).toBe(true);
|
||||
expect(featureFlags.isActive('flag-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('can set and clear state using helper methods', () => {
|
||||
const featureFlags = mockApis.featureFlags();
|
||||
featureFlags.setState({ 'test-flag': FeatureFlagState.Active });
|
||||
expect(featureFlags.getState()).toEqual({
|
||||
'test-flag': FeatureFlagState.Active,
|
||||
});
|
||||
featureFlags.clearState();
|
||||
expect(featureFlags.getState()).toEqual({});
|
||||
});
|
||||
|
||||
it('can create a mock and make assertions on it', () => {
|
||||
const featureFlags = mockApis.featureFlags.mock({
|
||||
isActive: jest.fn(() => true),
|
||||
});
|
||||
expect(featureFlags.isActive('test')).toBe(true);
|
||||
expect(featureFlags.isActive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-exported APIs from test-utils', () => {
|
||||
it('should have analytics', () => {
|
||||
expect(mockApis.analytics).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have config', () => {
|
||||
expect(mockApis.config).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have discovery', () => {
|
||||
expect(mockApis.discovery).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have identity', () => {
|
||||
expect(mockApis.identity).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have permission', () => {
|
||||
expect(mockApis.permission).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have storage', () => {
|
||||
expect(mockApis.storage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have translation', () => {
|
||||
expect(mockApis.translation).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,465 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
alertApiRef,
|
||||
analyticsApiRef,
|
||||
configApiRef,
|
||||
discoveryApiRef,
|
||||
errorApiRef,
|
||||
fetchApiRef,
|
||||
featureFlagsApiRef,
|
||||
identityApiRef,
|
||||
storageApiRef,
|
||||
translationApiRef,
|
||||
type AnalyticsApi,
|
||||
type ConfigApi,
|
||||
type DiscoveryApi,
|
||||
type ErrorApi,
|
||||
type FetchApi,
|
||||
type IdentityApi,
|
||||
type StorageApi,
|
||||
type TranslationApi,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
permissionApiRef,
|
||||
type PermissionApi,
|
||||
} from '@backstage/plugin-permission-react';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import {
|
||||
AuthorizeResult,
|
||||
EvaluatePermissionRequest,
|
||||
} from '@backstage/plugin-permission-common';
|
||||
import { MockAlertApi } from './AlertApi';
|
||||
import {
|
||||
MockFeatureFlagsApi,
|
||||
MockFeatureFlagsApiOptions,
|
||||
} from './FeatureFlagsApi';
|
||||
import { MockAnalyticsApi } from './AnalyticsApi';
|
||||
import { MockConfigApi } from './ConfigApi';
|
||||
import { MockErrorApi, MockErrorApiOptions } from './ErrorApi';
|
||||
import { MockFetchApi, MockFetchApiOptions } from './FetchApi';
|
||||
import { MockStorageApi } from './StorageApi';
|
||||
import { MockPermissionApi } from './PermissionApi';
|
||||
import { MockTranslationApi } from './TranslationApi';
|
||||
import {
|
||||
mockWithApiFactory,
|
||||
type MockWithApiFactory,
|
||||
} from './MockWithApiFactory';
|
||||
import { createApiMock } from './createApiMock';
|
||||
|
||||
/**
|
||||
* Mock implementations of the core utility APIs, to be used in tests.
|
||||
*
|
||||
* @public
|
||||
* @remarks
|
||||
*
|
||||
* There are some variations among the APIs depending on what needs tests
|
||||
* might have, but overall there are two main usage patterns:
|
||||
*
|
||||
* 1: Creating an actual fake API instance, often with a simplified version
|
||||
* of functionality, by calling the mock API itself as a function.
|
||||
*
|
||||
* ```ts
|
||||
* // The function often accepts parameters that control its behavior
|
||||
* const foo = mockApis.foo();
|
||||
* ```
|
||||
*
|
||||
* 2: Creating a mock API, where all methods are replaced with jest mocks, by
|
||||
* calling the API's `mock` function.
|
||||
*
|
||||
* ```ts
|
||||
* // You can optionally supply a subset of its methods to implement
|
||||
* const foo = mockApis.foo.mock({
|
||||
* someMethod: () => 'mocked result',
|
||||
* });
|
||||
* // After exercising your test, you can make assertions on the mock:
|
||||
* expect(foo.someMethod).toHaveBeenCalledTimes(2);
|
||||
* expect(foo.otherMethod).toHaveBeenCalledWith(testData);
|
||||
* ```
|
||||
*/
|
||||
export namespace mockApis {
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/frontend-plugin-api#AlertApi}.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
*
|
||||
* ```tsx
|
||||
* const alertApi = mockApis.alert();
|
||||
* alertApi.post({ message: 'Test alert' });
|
||||
* expect(alertApi.getAlerts()).toHaveLength(1);
|
||||
* ```
|
||||
*/
|
||||
export function alert(): MockWithApiFactory<MockAlertApi> {
|
||||
const instance = new MockAlertApi();
|
||||
return mockWithApiFactory(
|
||||
alertApiRef,
|
||||
instance,
|
||||
) as MockWithApiFactory<MockAlertApi>;
|
||||
}
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}.
|
||||
*
|
||||
* @see {@link @backstage/frontend-plugin-api#mockApis.alert}
|
||||
* @public
|
||||
*/
|
||||
export namespace alert {
|
||||
/**
|
||||
* Creates a mock implementation of
|
||||
* {@link @backstage/frontend-plugin-api#AlertApi}. All methods are
|
||||
* replaced with jest mock functions, and you can optionally pass in a
|
||||
* subset of methods with an explicit implementation.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const mock = createApiMock(alertApiRef, () => ({
|
||||
post: jest.fn(),
|
||||
alert$: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
*
|
||||
* ```tsx
|
||||
* const featureFlagsApi = mockApis.featureFlags({
|
||||
* initialStates: { 'my-feature': FeatureFlagState.Active },
|
||||
* });
|
||||
* expect(featureFlagsApi.isActive('my-feature')).toBe(true);
|
||||
* ```
|
||||
*/
|
||||
export function featureFlags(
|
||||
options?: MockFeatureFlagsApiOptions,
|
||||
): MockWithApiFactory<MockFeatureFlagsApi> {
|
||||
const instance = new MockFeatureFlagsApi(options);
|
||||
return mockWithApiFactory(
|
||||
featureFlagsApiRef,
|
||||
instance,
|
||||
) as MockWithApiFactory<MockFeatureFlagsApi>;
|
||||
}
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
|
||||
*
|
||||
* @see {@link @backstage/frontend-plugin-api#mockApis.featureFlags}
|
||||
* @public
|
||||
*/
|
||||
export namespace featureFlags {
|
||||
/**
|
||||
* Creates a mock implementation of
|
||||
* {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. All methods are
|
||||
* replaced with jest mock functions, and you can optionally pass in a
|
||||
* subset of methods with an explicit implementation.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const mock = createApiMock(featureFlagsApiRef, () => ({
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: jest.fn(),
|
||||
isActive: jest.fn(),
|
||||
save: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/core-plugin-api#AnalyticsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function analytics(): MockAnalyticsApi &
|
||||
MockWithApiFactory<AnalyticsApi> {
|
||||
const instance = new MockAnalyticsApi();
|
||||
return mockWithApiFactory(analyticsApiRef, instance) as MockAnalyticsApi &
|
||||
MockWithApiFactory<AnalyticsApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/core-plugin-api#AnalyticsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace analytics {
|
||||
export const mock = createApiMock(analyticsApiRef, () => ({
|
||||
captureEvent: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
|
||||
* By default returns the default translation.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function translation(): MockTranslationApi &
|
||||
MockWithApiFactory<TranslationApi> {
|
||||
const instance = MockTranslationApi.create();
|
||||
return mockWithApiFactory(
|
||||
translationApiRef,
|
||||
instance,
|
||||
) as MockTranslationApi & MockWithApiFactory<TranslationApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/core-plugin-api/alpha#TranslationApi}.
|
||||
*
|
||||
* @see {@link @backstage/frontend-plugin-api#mockApis.translation}
|
||||
* @public
|
||||
*/
|
||||
export namespace translation {
|
||||
/**
|
||||
* Creates a mock of {@link @backstage/core-plugin-api/alpha#TranslationApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const mock = createApiMock(translationApiRef, () => ({
|
||||
getTranslation: jest.fn(),
|
||||
translation$: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/core-plugin-api#ConfigApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function config(options?: {
|
||||
data?: JsonObject;
|
||||
}): MockConfigApi & MockWithApiFactory<ConfigApi> {
|
||||
const instance = new MockConfigApi({ data: options?.data ?? {} });
|
||||
return mockWithApiFactory(configApiRef, instance) as MockConfigApi &
|
||||
MockWithApiFactory<ConfigApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/core-plugin-api#ConfigApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace config {
|
||||
export const mock = createApiMock(configApiRef, () => ({
|
||||
has: jest.fn(),
|
||||
keys: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getOptional: jest.fn(),
|
||||
getConfig: jest.fn(),
|
||||
getOptionalConfig: jest.fn(),
|
||||
getConfigArray: jest.fn(),
|
||||
getOptionalConfigArray: jest.fn(),
|
||||
getNumber: jest.fn(),
|
||||
getOptionalNumber: jest.fn(),
|
||||
getBoolean: jest.fn(),
|
||||
getOptionalBoolean: jest.fn(),
|
||||
getString: jest.fn(),
|
||||
getOptionalString: jest.fn(),
|
||||
getStringArray: jest.fn(),
|
||||
getOptionalStringArray: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/core-plugin-api#DiscoveryApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function discovery(options?: {
|
||||
baseUrl?: string;
|
||||
}): DiscoveryApi & MockWithApiFactory<DiscoveryApi> {
|
||||
const baseUrl = options?.baseUrl ?? 'http://example.com';
|
||||
const instance: DiscoveryApi = {
|
||||
async getBaseUrl(pluginId: string) {
|
||||
return `${baseUrl}/api/${pluginId}`;
|
||||
},
|
||||
};
|
||||
return mockWithApiFactory(discoveryApiRef, instance) as DiscoveryApi &
|
||||
MockWithApiFactory<DiscoveryApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/core-plugin-api#DiscoveryApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace discovery {
|
||||
export const mock = createApiMock(discoveryApiRef, () => ({
|
||||
getBaseUrl: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/core-plugin-api#IdentityApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function identity(options?: {
|
||||
userEntityRef?: string;
|
||||
ownershipEntityRefs?: string[];
|
||||
token?: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
picture?: string;
|
||||
}): MockWithApiFactory<IdentityApi> {
|
||||
const {
|
||||
userEntityRef = 'user:default/test',
|
||||
ownershipEntityRefs = ['user:default/test'],
|
||||
token,
|
||||
email,
|
||||
displayName,
|
||||
picture,
|
||||
} = options ?? {};
|
||||
const instance: IdentityApi = {
|
||||
async getBackstageIdentity() {
|
||||
return { type: 'user', ownershipEntityRefs, userEntityRef };
|
||||
},
|
||||
async getCredentials() {
|
||||
return { token };
|
||||
},
|
||||
async getProfileInfo() {
|
||||
return { email, displayName, picture };
|
||||
},
|
||||
async signOut() {},
|
||||
};
|
||||
return mockWithApiFactory(identityApiRef, instance) as IdentityApi &
|
||||
MockWithApiFactory<IdentityApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/core-plugin-api#IdentityApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace identity {
|
||||
export const mock = createApiMock(identityApiRef, () => ({
|
||||
getBackstageIdentity: jest.fn(),
|
||||
getCredentials: jest.fn(),
|
||||
getProfileInfo: jest.fn(),
|
||||
signOut: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/plugin-permission-react#PermissionApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function permission(options?: {
|
||||
authorize?:
|
||||
| AuthorizeResult.ALLOW
|
||||
| AuthorizeResult.DENY
|
||||
| ((
|
||||
request: EvaluatePermissionRequest,
|
||||
) => AuthorizeResult.ALLOW | AuthorizeResult.DENY);
|
||||
}): MockPermissionApi & MockWithApiFactory<PermissionApi> {
|
||||
const authorizeInput = options?.authorize;
|
||||
const handler =
|
||||
typeof authorizeInput === 'function'
|
||||
? authorizeInput
|
||||
: () => authorizeInput ?? AuthorizeResult.ALLOW;
|
||||
const instance = new MockPermissionApi(handler);
|
||||
return mockWithApiFactory(permissionApiRef, instance) as MockPermissionApi &
|
||||
MockWithApiFactory<PermissionApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/plugin-permission-react#PermissionApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace permission {
|
||||
export const mock = createApiMock(permissionApiRef, () => ({
|
||||
authorize: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/core-plugin-api#StorageApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function storage(options?: {
|
||||
data?: JsonObject;
|
||||
}): MockStorageApi & MockWithApiFactory<StorageApi> {
|
||||
const instance = MockStorageApi.create(options?.data);
|
||||
return mockWithApiFactory(storageApiRef, instance) as MockStorageApi &
|
||||
MockWithApiFactory<StorageApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/core-plugin-api#StorageApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace storage {
|
||||
export const mock = createApiMock(storageApiRef, () => ({
|
||||
forBucket: jest.fn(),
|
||||
snapshot: jest.fn(),
|
||||
set: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
observe$: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/core-plugin-api#ErrorApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function error(
|
||||
options?: MockErrorApiOptions,
|
||||
): MockErrorApi & MockWithApiFactory<ErrorApi> {
|
||||
const instance = new MockErrorApi(options);
|
||||
return mockWithApiFactory(errorApiRef, instance) as MockErrorApi &
|
||||
MockWithApiFactory<ErrorApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/core-plugin-api#ErrorApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace error {
|
||||
export const mock = createApiMock(errorApiRef, () => ({
|
||||
post: jest.fn(),
|
||||
error$: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake implementation of {@link @backstage/core-plugin-api#FetchApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function fetch(
|
||||
options?: MockFetchApiOptions,
|
||||
): MockFetchApi & MockWithApiFactory<FetchApi> {
|
||||
const instance = new MockFetchApi(options);
|
||||
return mockWithApiFactory(fetchApiRef, instance) as MockFetchApi &
|
||||
MockWithApiFactory<FetchApi>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/core-plugin-api#FetchApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export namespace fetch {
|
||||
export const mock = createApiMock(fetchApiRef, () => ({
|
||||
fetch: jest.fn(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -173,11 +173,11 @@ describe('createExtensionTester', () => {
|
||||
});
|
||||
|
||||
const tester = createExtensionTester(extension, {
|
||||
apis: [[analyticsApiRef, analyticsApiMock]],
|
||||
apis: [[analyticsApiRef, analyticsApiMock] as const],
|
||||
});
|
||||
|
||||
renderInTestApp(tester.reactElement(), {
|
||||
apis: [[analyticsApiRef, analyticsApiMock]],
|
||||
apis: [[analyticsApiRef, analyticsApiMock] as const],
|
||||
});
|
||||
|
||||
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||
|
||||
@@ -38,7 +38,7 @@ import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/read
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { createErrorCollector } from '../../../frontend-app-api/src/wiring/createErrorCollector';
|
||||
import { OpaqueExtensionDefinition } from '@internal/frontend';
|
||||
import { TestApiRegistry, type TestApiPairs } from '../utils';
|
||||
import { resolveTestApiEntries, TestApiPairs } from '../apis/TestApiProvider';
|
||||
|
||||
/**
|
||||
* Represents a snapshot of an extension in the app tree.
|
||||
@@ -96,7 +96,7 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
|
||||
/** @internal */
|
||||
static forSubject<
|
||||
T extends ExtensionDefinitionParameters,
|
||||
TApiPairs extends any[],
|
||||
const TApiPairs extends any[],
|
||||
>(
|
||||
subject: ExtensionDefinition<T>,
|
||||
options?: {
|
||||
@@ -281,9 +281,7 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
|
||||
collector,
|
||||
);
|
||||
|
||||
const apiHolder = this.#apis
|
||||
? TestApiRegistry.from(...this.#apis)
|
||||
: TestApiRegistry.from();
|
||||
const apiHolder = resolveTestApiEntries(this.#apis ?? []);
|
||||
|
||||
instantiateAppNodeTree(tree.root, apiHolder, collector);
|
||||
|
||||
|
||||
@@ -16,11 +16,8 @@
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { screen, fireEvent } from '@testing-library/react';
|
||||
import {
|
||||
MockAnalyticsApi,
|
||||
TestApiProvider,
|
||||
} from '@backstage/frontend-test-utils';
|
||||
import { analyticsApiRef, useAnalytics } from '@backstage/frontend-plugin-api';
|
||||
import { mockApis, TestApiProvider } from '@backstage/frontend-test-utils';
|
||||
import { useAnalytics } from '@backstage/frontend-plugin-api';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import { renderInTestApp } from './renderInTestApp';
|
||||
|
||||
@@ -47,10 +44,10 @@ describe('renderInTestApp', () => {
|
||||
);
|
||||
};
|
||||
|
||||
const analyticsApiMock = new MockAnalyticsApi();
|
||||
const analyticsApiMock = mockApis.analytics();
|
||||
|
||||
renderInTestApp(
|
||||
<TestApiProvider apis={[[analyticsApiRef, analyticsApiMock]]}>
|
||||
<TestApiProvider apis={[analyticsApiMock]}>
|
||||
<IndexPage />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
@@ -94,10 +91,10 @@ describe('renderInTestApp', () => {
|
||||
);
|
||||
};
|
||||
|
||||
const analyticsApiMock = new MockAnalyticsApi();
|
||||
const analyticsApiMock = mockApis.analytics();
|
||||
|
||||
renderInTestApp(<IndexPage />, {
|
||||
apis: [[analyticsApiRef, analyticsApiMock]],
|
||||
apis: [analyticsApiMock],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Click me' }));
|
||||
|
||||
@@ -32,12 +32,14 @@ import {
|
||||
FrontendFeature,
|
||||
createFrontendModule,
|
||||
createApiFactory,
|
||||
type ApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { RouterBlueprint } from '@backstage/plugin-app-react';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
import { type TestApiPairs } from '../utils';
|
||||
import { getMockApiFactory } from '../apis/MockWithApiFactory';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp';
|
||||
import { TestApiPairs } from '../apis/TestApiProvider';
|
||||
|
||||
const DEFAULT_MOCK_CONFIG = {
|
||||
app: { baseUrl: 'http://localhost:3000' },
|
||||
@@ -88,11 +90,10 @@ export type TestAppOptions<TApiPairs extends any[] = any[]> = {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { identityApiRef } from '@backstage/frontend-plugin-api';
|
||||
* import { mockApis } from '@backstage/frontend-test-utils';
|
||||
*
|
||||
* renderInTestApp(<MyComponent />, {
|
||||
* apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]],
|
||||
* apis: [mockApis.identity({ userEntityRef: 'user:default/guest' })],
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
@@ -163,7 +164,7 @@ const appPluginOverride = appPlugin.withOverrides({
|
||||
* @public
|
||||
* Renders the given element in a test app, for use in unit tests.
|
||||
*/
|
||||
export function renderInTestApp<TApiPairs extends any[] = any[]>(
|
||||
export function renderInTestApp<const TApiPairs extends any[] = any[]>(
|
||||
element: JSX.Element,
|
||||
options?: TestAppOptions<TApiPairs>,
|
||||
): RenderResult {
|
||||
@@ -241,9 +242,14 @@ export function renderInTestApp<TApiPairs extends any[] = any[]>(
|
||||
},
|
||||
]),
|
||||
__internal: options?.apis && {
|
||||
apiFactoryOverrides: options.apis.map(([apiRef, implementation]) =>
|
||||
createApiFactory(apiRef, implementation),
|
||||
),
|
||||
apiFactoryOverrides: options.apis.map(entry => {
|
||||
const mockFactory = getMockApiFactory(entry);
|
||||
if (mockFactory) {
|
||||
return mockFactory;
|
||||
}
|
||||
const [apiRef, implementation] = entry as readonly [ApiRef<any>, any];
|
||||
return createApiFactory(apiRef, implementation);
|
||||
}),
|
||||
},
|
||||
} as CreateSpecializedAppInternalOptions);
|
||||
|
||||
|
||||
@@ -25,16 +25,18 @@ import {
|
||||
ExtensionDefinition,
|
||||
FrontendFeature,
|
||||
RouteRef,
|
||||
type ApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, type RenderResult } from '@testing-library/react';
|
||||
import appPlugin from '@backstage/plugin-app';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { RouterBlueprint } from '@backstage/plugin-app-react';
|
||||
import { type TestApiPairs } from '../utils';
|
||||
import { getMockApiFactory } from '../apis/MockWithApiFactory';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp';
|
||||
import { TestApiPairs } from '../apis/TestApiProvider';
|
||||
|
||||
const DEFAULT_MOCK_CONFIG = {
|
||||
app: { baseUrl: 'http://localhost:3000' },
|
||||
@@ -89,11 +91,10 @@ export type RenderTestAppOptions<TApiPairs extends any[] = any[]> = {
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { identityApiRef } from '@backstage/frontend-plugin-api';
|
||||
* import { mockApis } from '@backstage/frontend-test-utils';
|
||||
*
|
||||
* renderTestApp({
|
||||
* apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]],
|
||||
* apis: [mockApis.identity({ userEntityRef: 'user:default/guest' })],
|
||||
* extensions: [...],
|
||||
* })
|
||||
* ```
|
||||
@@ -115,12 +116,12 @@ const appPluginOverride = appPlugin.withOverrides({
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function renderTestApp<TApiPairs extends any[] = any[]>(
|
||||
options: RenderTestAppOptions<TApiPairs>,
|
||||
) {
|
||||
const extensions = [...(options.extensions ?? [])];
|
||||
export function renderTestApp<const TApiPairs extends any[] = any[]>(
|
||||
options?: RenderTestAppOptions<TApiPairs>,
|
||||
): RenderResult {
|
||||
const extensions = [...(options?.extensions ?? [])];
|
||||
|
||||
if (options.mountedRoutes) {
|
||||
if (options?.mountedRoutes) {
|
||||
for (const [path, routeRef] of Object.entries(options.mountedRoutes)) {
|
||||
extensions.push(
|
||||
createExtension({
|
||||
@@ -150,7 +151,7 @@ export function renderTestApp<TApiPairs extends any[] = any[]>(
|
||||
params: {
|
||||
component: ({ children }) => (
|
||||
<MemoryRouter
|
||||
initialEntries={options.initialRouteEntries}
|
||||
initialEntries={options?.initialRouteEntries}
|
||||
future={{
|
||||
v7_relativeSplatPath: true,
|
||||
v7_startTransition: true,
|
||||
@@ -170,7 +171,7 @@ export function renderTestApp<TApiPairs extends any[] = any[]>(
|
||||
appPluginOverride,
|
||||
];
|
||||
|
||||
if (options.features) {
|
||||
if (options?.features) {
|
||||
features.push(...options.features);
|
||||
}
|
||||
|
||||
@@ -183,9 +184,14 @@ export function renderTestApp<TApiPairs extends any[] = any[]>(
|
||||
},
|
||||
]),
|
||||
__internal: options?.apis && {
|
||||
apiFactoryOverrides: options.apis.map(([apiRef, implementation]) =>
|
||||
createApiFactory(apiRef, implementation),
|
||||
),
|
||||
apiFactoryOverrides: options.apis.map(entry => {
|
||||
const mockFactory = getMockApiFactory(entry);
|
||||
if (mockFactory) {
|
||||
return mockFactory;
|
||||
}
|
||||
const [apiRef, implementation] = entry as readonly [ApiRef<any>, any];
|
||||
return createApiFactory(apiRef, implementation);
|
||||
}),
|
||||
},
|
||||
} as CreateSpecializedAppInternalOptions);
|
||||
|
||||
|
||||
@@ -22,10 +22,6 @@
|
||||
|
||||
export * from './apis';
|
||||
export * from './app';
|
||||
export * from './utils';
|
||||
|
||||
// Explicit export to satisfy API Extractor
|
||||
export type { TestApiPairs } from './utils';
|
||||
|
||||
export { withLogCollector } from '@backstage/test-utils';
|
||||
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { ApiProvider } from '../../../core-app-api/src/apis/system';
|
||||
import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* Helper type for representing an API reference paired with a partial implementation.
|
||||
* @public
|
||||
*/
|
||||
export type TestApiProviderPropsApiPair<TApi> = TApi extends infer TImpl
|
||||
? readonly [ApiRef<TApi>, Partial<TImpl>]
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Helper type for representing an array of API reference pairs.
|
||||
* @public
|
||||
*/
|
||||
export type TestApiProviderPropsApiPairs<TApiPairs> = {
|
||||
[TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair<TApiPairs[TIndex]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shorter alias for TestApiProviderPropsApiPairs for use in function signatures.
|
||||
* @public
|
||||
*/
|
||||
export type TestApiPairs<TApiPairs> = TestApiProviderPropsApiPairs<TApiPairs>;
|
||||
|
||||
/**
|
||||
* Properties for the {@link TestApiProvider} component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type TestApiProviderProps<TApiPairs extends any[]> = {
|
||||
apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>];
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* The `TestApiRegistry` is an {@link @backstage/frontend-plugin-api#ApiHolder} implementation
|
||||
* that is particularly well suited for development and test environments such as
|
||||
* unit tests, storybooks, and isolated plugin development setups.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* For most test scenarios, prefer using the `apis` option in `renderInTestApp` or
|
||||
* `createExtensionTester` instead of creating a registry directly.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export class TestApiRegistry implements ApiHolder {
|
||||
/**
|
||||
* Creates a new {@link TestApiRegistry} with a list of API implementation pairs.
|
||||
*
|
||||
* Similar to the {@link TestApiProvider}, there is no need to provide a full
|
||||
* implementation of each API, it's enough to implement the methods that are tested.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { identityApiRef } from '@backstage/frontend-plugin-api';
|
||||
* import { mockApis } from '@backstage/frontend-test-utils';
|
||||
*
|
||||
* const apis = TestApiRegistry.from(
|
||||
* [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })],
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
* @param apis - A list of pairs mapping an ApiRef to its respective implementation.
|
||||
*/
|
||||
static from<TApiPairs extends any[]>(
|
||||
...apis: readonly [...TestApiProviderPropsApiPairs<TApiPairs>]
|
||||
) {
|
||||
return new TestApiRegistry(
|
||||
new Map(apis.map(([api, impl]) => [api.id, impl])),
|
||||
);
|
||||
}
|
||||
|
||||
private constructor(private readonly apis: Map<string, unknown>) {}
|
||||
|
||||
/**
|
||||
* Returns an implementation of the API.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
get<T>(api: ApiRef<T>): T | undefined {
|
||||
return this.apis.get(api.id) as T | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `TestApiProvider` is a Utility API context provider for standalone rendering
|
||||
* scenarios where you're not using `renderInTestApp` or other test utilities.
|
||||
*
|
||||
* It lets you provide any number of API implementations, without necessarily
|
||||
* having to fully implement each of the APIs.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* For most test scenarios, prefer using the `apis` option in `renderInTestApp` or
|
||||
* `createExtensionTester` instead of wrapping components with `TestApiProvider`.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { render } from '\@testing-library/react';
|
||||
* import { identityApiRef } from '\@backstage/frontend-plugin-api';
|
||||
* import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils';
|
||||
*
|
||||
* render(
|
||||
* <TestApiProvider
|
||||
* apis={[[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]]}
|
||||
* >
|
||||
* <MyComponent />
|
||||
* </TestApiProvider>
|
||||
* );
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const TestApiProvider = <T extends any[]>(
|
||||
props: TestApiProviderProps<T>,
|
||||
) => {
|
||||
return (
|
||||
<ApiProvider
|
||||
apis={TestApiRegistry.from(...props.apis)}
|
||||
children={props.children}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -89,7 +89,21 @@ function findAllDeps(declSrc: string) {
|
||||
.filter(n => !n.startsWith('.'))
|
||||
// We allow references to these without an explicit dependency.
|
||||
.filter(n => !['node', 'react'].includes(n));
|
||||
return Array.from(new Set([...importedDeps, ...referencedDeps]));
|
||||
|
||||
// Detect ambient global type namespaces (e.g. jest.Mocked, jest.MockInstance)
|
||||
// that are used in type positions but don't appear as explicit imports.
|
||||
// Strip comments first to avoid false positives from JSDoc examples.
|
||||
const strippedSrc = declSrc
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/.*$/gm, '');
|
||||
const ambientDeps: string[] = [];
|
||||
if (/\bjest\.\w/.test(strippedSrc)) {
|
||||
ambientDeps.push('jest');
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set([...importedDeps, ...referencedDeps, ...ambientDeps]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -74,12 +74,16 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/jest": "*",
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0",
|
||||
"react-router-dom": "^6.30.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/jest": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
createTestEntityPage,
|
||||
catalogApiMock,
|
||||
} from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import catalogGraphPlugin from './alpha';
|
||||
import { catalogGraphRouteRef } from './routes';
|
||||
import { catalogGraphApiRef, DefaultCatalogGraphApi } from './api';
|
||||
@@ -58,7 +57,7 @@ describe('catalog-graph alpha plugin', () => {
|
||||
'/catalog-graph': catalogGraphRouteRef,
|
||||
},
|
||||
apis: [
|
||||
[catalogApiRef, catalogApiMock({ entities: [entity] })],
|
||||
catalogApiMock({ entities: [entity] }),
|
||||
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
|
||||
],
|
||||
});
|
||||
@@ -95,7 +94,7 @@ describe('catalog-graph alpha plugin', () => {
|
||||
'/catalog-graph': catalogGraphRouteRef,
|
||||
},
|
||||
apis: [
|
||||
[catalogApiRef, catalogApiMock({ entities: [entity] })],
|
||||
catalogApiMock({ entities: [entity] }),
|
||||
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
|
||||
],
|
||||
});
|
||||
@@ -131,7 +130,7 @@ describe('catalog-graph alpha plugin', () => {
|
||||
'/catalog-graph': catalogGraphRouteRef,
|
||||
},
|
||||
apis: [
|
||||
[catalogApiRef, catalogApiMock({ entities: [entity] })],
|
||||
catalogApiMock({ entities: [entity] }),
|
||||
[catalogGraphApiRef, new DefaultCatalogGraphApi()],
|
||||
],
|
||||
});
|
||||
|
||||
+18
-25
@@ -14,19 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { AlertApi, alertApiRef, errorApiRef } from '@backstage/core-plugin-api';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
mockApis,
|
||||
renderWithEffects,
|
||||
TestApiRegistry,
|
||||
} from '@backstage/test-utils';
|
||||
import { renderWithEffects } from '@backstage/test-utils';
|
||||
import { mockApis, TestApiProvider } from '@backstage/frontend-test-utils';
|
||||
import { waitFor, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SelectedKindsFilter } from './SelectedKindsFilter';
|
||||
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
|
||||
|
||||
const catalogApi = catalogApiMock.mock({
|
||||
getEntityFacets: jest.fn().mockResolvedValue({
|
||||
@@ -40,28 +33,28 @@ const catalogApi = catalogApiMock.mock({
|
||||
},
|
||||
}),
|
||||
});
|
||||
const apis = TestApiRegistry.from(
|
||||
[catalogApiRef, catalogApi],
|
||||
[alertApiRef, {} as AlertApi],
|
||||
[translationApiRef, mockApis.translation()],
|
||||
[errorApiRef, { post: jest.fn() }],
|
||||
);
|
||||
const apis = [
|
||||
mockApis.alert(),
|
||||
mockApis.translation(),
|
||||
mockApis.error(),
|
||||
catalogApi,
|
||||
] as const;
|
||||
|
||||
describe('<SelectedKindsFilter/>', () => {
|
||||
it('should not explode while loading', async () => {
|
||||
const { baseElement } = await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<TestApiProvider apis={apis}>
|
||||
<SelectedKindsFilter value={['api', 'component']} onChange={() => {}} />
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
expect(baseElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render current value', async () => {
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<TestApiProvider apis={apis}>
|
||||
<SelectedKindsFilter value={['api', 'component']} onChange={() => {}} />
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('API')).toBeInTheDocument();
|
||||
@@ -71,9 +64,9 @@ describe('<SelectedKindsFilter/>', () => {
|
||||
it('should select value', async () => {
|
||||
const onChange = jest.fn();
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<TestApiProvider apis={apis}>
|
||||
<SelectedKindsFilter value={['api', 'component']} onChange={onChange} />
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Open'));
|
||||
@@ -89,12 +82,12 @@ describe('<SelectedKindsFilter/>', () => {
|
||||
it('should return undefined if all values are selected', async () => {
|
||||
const onChange = jest.fn();
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<TestApiProvider apis={apis}>
|
||||
<SelectedKindsFilter
|
||||
value={['api', 'component', 'system', 'domain']}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await userEvent.click(screen.getByLabelText('Open'));
|
||||
|
||||
@@ -112,9 +105,9 @@ describe('<SelectedKindsFilter/>', () => {
|
||||
it('should return all values when cleared', async () => {
|
||||
const onChange = jest.fn();
|
||||
await renderWithEffects(
|
||||
<ApiProvider apis={apis}>
|
||||
<TestApiProvider apis={apis}>
|
||||
<SelectedKindsFilter value={[]} onChange={onChange} />
|
||||
</ApiProvider>,
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('combobox'));
|
||||
|
||||
@@ -68,7 +68,6 @@
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/integration-react": "workspace:^",
|
||||
"@backstage/plugin-catalog-common": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
@@ -91,6 +90,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-catalog-common": "workspace:^",
|
||||
"@backstage/plugin-scaffolder-common": "workspace:^",
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
@@ -107,12 +107,16 @@
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@types/react": "^17.0.0 || ^18.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0",
|
||||
"react-router-dom": "^6.30.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@backstage/frontend-test-utils": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityListContextProps } from '@backstage/plugin-catalog-react';
|
||||
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
|
||||
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
||||
import { MockWithApiFactory } from '@backstage/frontend-test-utils';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
// @public
|
||||
export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi;
|
||||
export function catalogApiMock(options?: {
|
||||
entities?: Entity[];
|
||||
}): MockWithApiFactory<CatalogApi>;
|
||||
|
||||
// @public
|
||||
export namespace catalogApiMock {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { fireEvent, waitFor, screen, within } from '@testing-library/react';
|
||||
import { capitalize } from 'lodash';
|
||||
import { catalogApiRef } from '../../api';
|
||||
@@ -65,12 +66,7 @@ describe('<EntityKindPicker/>', () => {
|
||||
} as GetEntityFacetsResponse),
|
||||
},
|
||||
],
|
||||
[
|
||||
alertApiRef,
|
||||
{
|
||||
post: jest.fn(),
|
||||
},
|
||||
],
|
||||
[alertApiRef, mockApis.alert()],
|
||||
);
|
||||
|
||||
it('renders available entity kinds', async () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { EntityKindFilter, EntityTypeFilter } from '../../filters';
|
||||
import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
|
||||
|
||||
const entities: Entity[] = [
|
||||
@@ -72,12 +73,7 @@ const apis = TestApiRegistry.from(
|
||||
} as GetEntityFacetsResponse),
|
||||
},
|
||||
],
|
||||
[
|
||||
alertApiRef,
|
||||
{
|
||||
post: jest.fn(),
|
||||
},
|
||||
],
|
||||
[alertApiRef, mockApis.alert()],
|
||||
);
|
||||
|
||||
describe('<EntityTypePicker/>', () => {
|
||||
|
||||
+10
-18
@@ -25,22 +25,12 @@ import { catalogApiRef } from '../../api';
|
||||
import { entityRouteRef } from '../../routes';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import * as state from './useUnregisterEntityDialogState';
|
||||
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
describe('UnregisterEntityDialog', () => {
|
||||
const alertApi: AlertApi = {
|
||||
post() {
|
||||
return undefined;
|
||||
},
|
||||
alert$() {
|
||||
throw new Error('not implemented');
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(alertApi, 'post').mockImplementation(() => {});
|
||||
});
|
||||
const alertApi = mockApis.alert();
|
||||
|
||||
const entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
@@ -340,11 +330,13 @@ describe('UnregisterEntityDialog', () => {
|
||||
await waitFor(() => {
|
||||
expect(deleteEntity).toHaveBeenCalled();
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
expect(alertApi.post).toHaveBeenCalledWith({
|
||||
message: 'Removed entity n',
|
||||
severity: 'success',
|
||||
display: 'transient',
|
||||
});
|
||||
expect(alertApi.getAlerts()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
message: 'Removed entity n',
|
||||
severity: 'success',
|
||||
display: 'transient',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { translationApiRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { mockApis, TestApiProvider } from '@backstage/test-utils';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { useMountEffect } from '@react-hookz/web';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import qs from 'qs';
|
||||
@@ -111,7 +112,7 @@ const createWrapper =
|
||||
[identityApiRef, mockIdentityApi],
|
||||
[storageApiRef, mockApis.storage()],
|
||||
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
|
||||
[alertApiRef, { post: jest.fn() }],
|
||||
[alertApiRef, mockApis.alert()],
|
||||
[translationApiRef, mockApis.translation()],
|
||||
[errorApiRef, { error$: jest.fn(), post: jest.fn() }],
|
||||
]}
|
||||
|
||||
@@ -14,42 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ApiFactory,
|
||||
ApiRef,
|
||||
createApiFactory,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { ApiFactory, createApiFactory } from '@backstage/frontend-plugin-api';
|
||||
import { InMemoryCatalogClient } from '@backstage/catalog-client/testUtils';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { ApiMock } from '@backstage/frontend-test-utils';
|
||||
|
||||
/** @internal */
|
||||
function simpleMock<TApi>(
|
||||
ref: ApiRef<TApi>,
|
||||
mockFactory: () => jest.Mocked<TApi>,
|
||||
): (partialImpl?: Partial<TApi>) => ApiMock<TApi> {
|
||||
return partialImpl => {
|
||||
const mock = mockFactory();
|
||||
if (partialImpl) {
|
||||
for (const [key, impl] of Object.entries(partialImpl)) {
|
||||
if (typeof impl === 'function') {
|
||||
(mock as any)[key].mockImplementation(impl);
|
||||
} else {
|
||||
(mock as any)[key] = impl;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.assign(mock, {
|
||||
factory: createApiFactory({
|
||||
api: ref,
|
||||
deps: {},
|
||||
factory: () => mock,
|
||||
}),
|
||||
}) as ApiMock<TApi>;
|
||||
};
|
||||
}
|
||||
import {
|
||||
createApiMock,
|
||||
attachMockApiFactory,
|
||||
type MockWithApiFactory,
|
||||
} from '@backstage/frontend-test-utils';
|
||||
|
||||
/**
|
||||
* Creates a fake catalog client that handles entities in memory storage. Note
|
||||
@@ -58,8 +32,11 @@ function simpleMock<TApi>(
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi {
|
||||
return new InMemoryCatalogClient(options);
|
||||
export function catalogApiMock(options?: {
|
||||
entities?: Entity[];
|
||||
}): MockWithApiFactory<CatalogApi> {
|
||||
const instance = new InMemoryCatalogClient(options);
|
||||
return attachMockApiFactory(catalogApiRef, instance);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +62,7 @@ export namespace catalogApiMock {
|
||||
* Creates a catalog client whose methods are mock functions, possibly with
|
||||
* some of them overloaded by the caller.
|
||||
*/
|
||||
export const mock = simpleMock(catalogApiRef, () => ({
|
||||
export const mock = createApiMock(catalogApiRef, () => ({
|
||||
getEntities: jest.fn(),
|
||||
getEntitiesByRefs: jest.fn(),
|
||||
queryEntities: jest.fn(),
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
EntityCardBlueprint,
|
||||
EntityContentBlueprint,
|
||||
} from '../alpha/blueprints';
|
||||
import { catalogApiRef } from '../api';
|
||||
|
||||
const mockEntity: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
@@ -386,7 +385,7 @@ describe('createTestEntityPage', () => {
|
||||
createTestEntityPage({ entity: mockEntity }),
|
||||
catalogApiCard,
|
||||
],
|
||||
apis: [[catalogApiRef, catalogApiMock({ entities: customEntities })]],
|
||||
apis: [catalogApiMock({ entities: customEntities })],
|
||||
});
|
||||
|
||||
// Should use the user-provided catalog API with custom entities, not mockEntity
|
||||
|
||||
@@ -32,11 +32,11 @@ import {
|
||||
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { permissionApiRef } from '@backstage/plugin-permission-react';
|
||||
import {
|
||||
mockApis,
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
TestApiRegistry,
|
||||
} from '@backstage/test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||
import { EntityLayout } from './EntityLayout';
|
||||
import { rootRouteRef, unregisterRedirectRouteRef } from '../../routes';
|
||||
@@ -52,7 +52,7 @@ describe('EntityLayout', () => {
|
||||
|
||||
const apis = TestApiRegistry.from(
|
||||
[catalogApiRef, catalogApiMock()],
|
||||
[alertApiRef, {} as AlertApi],
|
||||
[alertApiRef, mockApis.alert()],
|
||||
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
|
||||
[permissionApiRef, mockApis.permission()],
|
||||
);
|
||||
|
||||
@@ -22,13 +22,11 @@ import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
|
||||
describe('DeleteEntityDialog', () => {
|
||||
const alertApi: jest.Mocked<AlertApi> = {
|
||||
post: jest.fn(),
|
||||
alert$: jest.fn(),
|
||||
};
|
||||
const alertApi = mockApis.alert();
|
||||
|
||||
const catalogClient = catalogApiMock.mock();
|
||||
|
||||
@@ -123,7 +121,9 @@ describe('DeleteEntityDialog', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(catalogClient.removeEntityByUid).toHaveBeenCalledWith('123');
|
||||
expect(alertApi.post).toHaveBeenCalledWith({ message: 'no no no' });
|
||||
expect(alertApi.getAlerts()).toContainEqual(
|
||||
expect.objectContaining({ message: 'no no no' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,15 +23,12 @@ import { render, screen } from '@testing-library/react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { isKind } from './conditions';
|
||||
import { EntitySwitch } from './EntitySwitch';
|
||||
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
|
||||
import { LocalStorageFeatureFlags } from '@backstage/core-app-api';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils';
|
||||
|
||||
const mockFeatureFlagsApi = mockApis.featureFlags.mock();
|
||||
|
||||
const mockFeatureFlagsApi = new LocalStorageFeatureFlags();
|
||||
const Wrapper = ({ children }: { children?: ReactNode }) => (
|
||||
<TestApiProvider apis={[[featureFlagsApiRef, mockFeatureFlagsApi]]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
<TestApiProvider apis={[mockFeatureFlagsApi]}>{children}</TestApiProvider>
|
||||
);
|
||||
|
||||
describe('EntitySwitch', () => {
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
createTestEntityPage,
|
||||
catalogApiMock,
|
||||
} from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import orgPlugin from './alpha';
|
||||
|
||||
const EntityGroupProfileCard = orgPlugin.getExtension(
|
||||
@@ -230,12 +229,7 @@ describe('org plugin entity cards', () => {
|
||||
createTestEntityPage({ entity: groupEntity }),
|
||||
EntityMembersListCard,
|
||||
],
|
||||
apis: [
|
||||
[
|
||||
catalogApiRef,
|
||||
catalogApiMock({ entities: [groupEntity, memberUser] }),
|
||||
],
|
||||
],
|
||||
apis: [catalogApiMock({ entities: [groupEntity, memberUser] })],
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Alice Smith')).toBeInTheDocument();
|
||||
@@ -261,7 +255,7 @@ describe('org plugin entity cards', () => {
|
||||
createTestEntityPage({ entity: groupEntity }),
|
||||
EntityMembersListCard,
|
||||
],
|
||||
apis: [[catalogApiRef, catalogApiMock({ entities: [groupEntity] })]],
|
||||
apis: [catalogApiMock({ entities: [groupEntity] })],
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
"@backstage/plugin-catalog-common": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
|
||||
+7
-3
@@ -17,6 +17,7 @@
|
||||
import { useEntityTypeFilter } from '@backstage/plugin-catalog-react';
|
||||
import { TemplateCategoryPicker } from './TemplateCategoryPicker';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
@@ -26,10 +27,10 @@ jest.mock('@backstage/plugin-catalog-react', () => ({
|
||||
}));
|
||||
|
||||
describe('TemplateCategoryPicker', () => {
|
||||
const mockAlertApi = { post: jest.fn() };
|
||||
const mockAlertApi = mockApis.alert();
|
||||
|
||||
beforeEach(() => {
|
||||
mockAlertApi.post.mockClear();
|
||||
mockAlertApi.clearAlerts();
|
||||
});
|
||||
|
||||
it('should post the error to errorApi if an errors is returned', async () => {
|
||||
@@ -37,13 +38,16 @@ describe('TemplateCategoryPicker', () => {
|
||||
error: new Error('something broked'),
|
||||
});
|
||||
|
||||
mockAlertApi.clearAlerts();
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[alertApiRef, mockAlertApi]]}>
|
||||
<TemplateCategoryPicker />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(mockAlertApi.post).toHaveBeenCalledWith({
|
||||
expect(mockAlertApi.getAlerts().length).toBeGreaterThanOrEqual(1);
|
||||
expect(mockAlertApi.getAlerts()[0]).toMatchObject({
|
||||
message: expect.stringContaining('something broked'),
|
||||
severity: 'error',
|
||||
});
|
||||
|
||||
@@ -18,11 +18,10 @@ import { renderHook } from '@testing-library/react';
|
||||
import { useFilteredSchemaProperties } from './useFilteredSchemaProperties';
|
||||
import { TemplateParameterSchema } from '../../types';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
const mockFeatureFlagApi = {
|
||||
isActive: jest.fn(),
|
||||
};
|
||||
const mockFeatureFlagApi = mockApis.featureFlags.mock();
|
||||
|
||||
describe('useFilteredSchemaProperties', () => {
|
||||
it('should return the same manifest if no feature flag is set', () => {
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
import { useTemplateSchema } from './useTemplateSchema';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { TestApiProvider } from '@backstage/frontend-test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
|
||||
import { TemplateParameterSchema } from '../../types';
|
||||
|
||||
describe('useTemplateSchema', () => {
|
||||
@@ -49,11 +49,13 @@ describe('useTemplateSchema', () => {
|
||||
],
|
||||
};
|
||||
|
||||
const mockFeatureFlagsApi = mockApis.featureFlags.mock({
|
||||
isActive: jest.fn(() => false),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTemplateSchema(manifest), {
|
||||
wrapper: ({ children }: PropsWithChildren<{}>) => (
|
||||
<TestApiProvider
|
||||
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
|
||||
>
|
||||
<TestApiProvider apis={[mockFeatureFlagsApi]}>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
),
|
||||
@@ -119,7 +121,9 @@ describe('useTemplateSchema', () => {
|
||||
const { result } = renderHook(() => useTemplateSchema(manifest), {
|
||||
wrapper: ({ children }: PropsWithChildren<{}>) => (
|
||||
<TestApiProvider
|
||||
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
|
||||
apis={[
|
||||
mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
@@ -163,7 +167,9 @@ describe('useTemplateSchema', () => {
|
||||
const { result } = renderHook(() => useTemplateSchema(manifest), {
|
||||
wrapper: ({ children }: PropsWithChildren<{}>) => (
|
||||
<TestApiProvider
|
||||
apis={[[featureFlagsApiRef, { isActive: () => true }]]}
|
||||
apis={[
|
||||
mockApis.featureFlags.mock({ isActive: jest.fn(() => true) }),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
@@ -214,7 +220,9 @@ describe('useTemplateSchema', () => {
|
||||
const { result } = renderHook(() => useTemplateSchema(manifest), {
|
||||
wrapper: ({ children }: PropsWithChildren<{}>) => (
|
||||
<TestApiProvider
|
||||
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
|
||||
apis={[
|
||||
mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
@@ -252,7 +260,9 @@ describe('useTemplateSchema', () => {
|
||||
const { result } = renderHook(() => useTemplateSchema(manifest), {
|
||||
wrapper: ({ children }: PropsWithChildren<{}>) => (
|
||||
<TestApiProvider
|
||||
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
|
||||
apis={[
|
||||
mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
@@ -356,7 +366,9 @@ describe('useTemplateSchema', () => {
|
||||
const { result } = renderHook(() => useTemplateSchema(manifest), {
|
||||
wrapper: ({ children }: PropsWithChildren<{}>) => (
|
||||
<TestApiProvider
|
||||
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
|
||||
apis={[
|
||||
mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }),
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</TestApiProvider>
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@backstage/core-app-api": "workspace:^",
|
||||
"@backstage/dev-utils": "workspace:^",
|
||||
"@backstage/frontend-test-utils": "workspace:^",
|
||||
"@backstage/plugin-catalog": "workspace:^",
|
||||
"@backstage/plugin-permission-common": "workspace:^",
|
||||
"@backstage/plugin-techdocs": "workspace:^",
|
||||
|
||||
@@ -23,9 +23,10 @@ import {
|
||||
EntityKindFilter,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils';
|
||||
import { AlertApi, alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
|
||||
import { mockApis } from '@backstage/frontend-test-utils';
|
||||
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
|
||||
|
||||
const entities: Entity[] = [
|
||||
@@ -75,12 +76,7 @@ const apis = TestApiRegistry.from(
|
||||
} as GetEntityFacetsResponse),
|
||||
},
|
||||
],
|
||||
[
|
||||
alertApiRef,
|
||||
{
|
||||
post: jest.fn(),
|
||||
} as unknown as AlertApi,
|
||||
],
|
||||
[alertApiRef, mockApis.alert()],
|
||||
);
|
||||
|
||||
describe('<TemplateTypePicker/>', () => {
|
||||
|
||||
@@ -3207,6 +3207,11 @@ __metadata:
|
||||
yn: "npm:^4.0.0"
|
||||
zod: "npm:^3.25.76"
|
||||
zod-to-json-schema: "npm:^3.25.1"
|
||||
peerDependencies:
|
||||
"@types/jest": "*"
|
||||
peerDependenciesMeta:
|
||||
"@types/jest":
|
||||
optional: true
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -3954,26 +3959,38 @@ __metadata:
|
||||
dependencies:
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/core-app-api": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-app-api": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/plugin-app": "workspace:^"
|
||||
"@backstage/plugin-app-react": "workspace:^"
|
||||
"@backstage/plugin-permission-common": "workspace:^"
|
||||
"@backstage/plugin-permission-react": "workspace:^"
|
||||
"@backstage/test-utils": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
"@testing-library/jest-dom": "npm:^6.0.0"
|
||||
"@types/jest": "npm:*"
|
||||
"@types/react": "npm:^18.0.0"
|
||||
"@types/zen-observable": "npm:^0.8.0"
|
||||
i18next: "npm:^22.4.15"
|
||||
msw: "npm:^2.0.0"
|
||||
react: "npm:^18.0.2"
|
||||
react-dom: "npm:^18.0.2"
|
||||
react-router-dom: "npm:^6.30.2"
|
||||
zen-observable: "npm:^0.10.0"
|
||||
zod: "npm:^3.25.76"
|
||||
peerDependencies:
|
||||
"@testing-library/react": ^16.0.0
|
||||
"@types/jest": "*"
|
||||
"@types/react": ^17.0.0 || ^18.0.0
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0
|
||||
react-router-dom: ^6.30.2
|
||||
peerDependenciesMeta:
|
||||
"@types/jest":
|
||||
optional: true
|
||||
"@types/react":
|
||||
optional: true
|
||||
languageName: unknown
|
||||
@@ -5430,11 +5447,14 @@ __metadata:
|
||||
zen-observable: "npm:^0.10.0"
|
||||
zod: "npm:^3.25.76"
|
||||
peerDependencies:
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@types/react": ^17.0.0 || ^18.0.0
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0
|
||||
react-router-dom: ^6.30.2
|
||||
peerDependenciesMeta:
|
||||
"@backstage/frontend-test-utils":
|
||||
optional: true
|
||||
"@types/react":
|
||||
optional: true
|
||||
languageName: unknown
|
||||
@@ -7042,6 +7062,7 @@ __metadata:
|
||||
"@backstage/core-components": "workspace:^"
|
||||
"@backstage/core-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@backstage/plugin-catalog": "workspace:^"
|
||||
"@backstage/plugin-catalog-common": "workspace:^"
|
||||
"@backstage/plugin-catalog-react": "workspace:^"
|
||||
@@ -7111,6 +7132,7 @@ __metadata:
|
||||
"@backstage/dev-utils": "workspace:^"
|
||||
"@backstage/errors": "workspace:^"
|
||||
"@backstage/frontend-plugin-api": "workspace:^"
|
||||
"@backstage/frontend-test-utils": "workspace:^"
|
||||
"@backstage/integration": "workspace:^"
|
||||
"@backstage/integration-react": "workspace:^"
|
||||
"@backstage/plugin-catalog": "workspace:^"
|
||||
@@ -7967,11 +7989,14 @@ __metadata:
|
||||
zen-observable: "npm:^0.10.0"
|
||||
peerDependencies:
|
||||
"@testing-library/react": ^16.0.0
|
||||
"@types/jest": "*"
|
||||
"@types/react": ^17.0.0 || ^18.0.0
|
||||
react: ^17.0.0 || ^18.0.0
|
||||
react-dom: ^17.0.0 || ^18.0.0
|
||||
react-router-dom: ^6.30.2
|
||||
peerDependenciesMeta:
|
||||
"@types/jest":
|
||||
optional: true
|
||||
"@types/react":
|
||||
optional: true
|
||||
languageName: unknown
|
||||
|
||||
Reference in New Issue
Block a user