diff --git a/.changeset/beige-crabs-share.md b/.changeset/beige-crabs-share.md new file mode 100644 index 0000000000..6a1a42ffd8 --- /dev/null +++ b/.changeset/beige-crabs-share.md @@ -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`. diff --git a/.changeset/catalog-react-catalog-api-mock-shorthand.md b/.changeset/catalog-react-catalog-api-mock-shorthand.md new file mode 100644 index 0000000000..0761dffa0e --- /dev/null +++ b/.changeset/catalog-react-catalog-api-mock-shorthand.md @@ -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. diff --git a/.changeset/frontend-mock-apis-alert-featureflags.md b/.changeset/frontend-mock-apis-alert-featureflags.md new file mode 100644 index 0000000000..59fa8f59a1 --- /dev/null +++ b/.changeset/frontend-mock-apis-alert-featureflags.md @@ -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. diff --git a/.changeset/frontend-test-utils-mock-apis-and-shorthand.md b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md new file mode 100644 index 0000000000..c864cba776 --- /dev/null +++ b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md @@ -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(, { + apis: [[identityApiRef, mockApis.identity()]], +}); + +// After - mock APIs can be passed directly +renderInTestApp(, { + 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. diff --git a/.changeset/repo-tools-type-deps-ambient-jest.md b/.changeset/repo-tools-type-deps-ambient-jest.md new file mode 100644 index 0000000000..87c15e6f80 --- /dev/null +++ b/.changeset/repo-tools-type-deps-ambient-jest.md @@ -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. diff --git a/.changeset/scaffolder-react-test-utils-dep.md b/.changeset/scaffolder-react-test-utils-dep.md new file mode 100644 index 0000000000..88d3ee6309 --- /dev/null +++ b/.changeset/scaffolder-react-test-utils-dep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests. diff --git a/.changeset/scaffolder-test-utils-dep.md b/.changeset/scaffolder-test-utils-dep.md new file mode 100644 index 0000000000..c9b092bc26 --- /dev/null +++ b/.changeset/scaffolder-test-utils-dep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests. diff --git a/.changeset/test-utils-jest-peer-dep.md b/.changeset/test-utils-jest-peer-dep.md new file mode 100644 index 0000000000..39c8f1b8f6 --- /dev/null +++ b/.changeset/test-utils-jest-peer-dep.md @@ -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. diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 7154cae6fb..68c0fb0d52 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -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 diff --git a/docs/frontend-system/building-plugins/02-testing.md b/docs/frontend-system/building-plugins/02-testing.md index 9c6db0d3de..82f44df2eb 100644 --- a/docs/frontend-system/building-plugins/02-testing.md +++ b/docs/frontend-system/building-plugins/02-testing.md @@ -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'; diff --git a/docs/frontend-system/utility-apis/01-index.md b/docs/frontend-system/utility-apis/01-index.md index 57e895ca36..86d317ddb2 100644 --- a/docs/frontend-system/utility-apis/01-index.md +++ b/docs/frontend-system/utility-apis/01-index.md @@ -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). diff --git a/docs/frontend-system/utility-apis/05-testing.md b/docs/frontend-system/utility-apis/05-testing.md new file mode 100644 index 0000000000..ba1e93e505 --- /dev/null +++ b/docs/frontend-system/utility-apis/05-testing.md @@ -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(, { + 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(, { + 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( + + + , +); +``` + +## 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(, { + apis: [myApiMock({ greeting: 'Hi there!' })], +}); + +// Jest mock for assertions +const api = myApiMock.mock({ + greet: async () => 'mocked', +}); + +await renderInTestApp(, { + 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. diff --git a/microsite/sidebars.ts b/microsite/sidebars.ts index b5955702a9..1e2da6ff2b 100644 --- a/microsite/sidebars.ts +++ b/microsite/sidebars.ts @@ -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', ], ), ], diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 5dd541789c..65b06f59de 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -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 + } } } diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index f359a9d2b8..d0398d7256 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -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 } diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 4d1a0764ed..748eee15d3 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -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 = { + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; + +// @public +export function attachMockApiFactory( + apiRef: ApiRef, + implementation: TImpl, +): TImpl & { + [mockApiFactorySymbol]: ApiFactory; +}; + +// @public +export function createApiMock( + apiRef: ApiRef, + mockFactory: () => jest.Mocked, +): (partialImpl?: Partial) => ApiMock; // @public (undocumented) export function createExtensionTester< @@ -47,7 +87,11 @@ export function createExtensionTester< }, ): ExtensionTester>; -export { ErrorWithContext }; +// @public +export type ErrorWithContext = { + error: ErrorApiError; + context?: ErrorApiErrorContext; +}; // @public (undocumented) export class ExtensionQuery { @@ -100,6 +144,20 @@ export class ExtensionTester { snapshot(): ExtensionSnapshotNode; } +// @public +export class MockAlertApi implements AlertApi { + // (undocumented) + alert$(): Observable; + clearAlerts(): void; + getAlerts(): AlertMessage[]; + // (undocumented) + post(alert: AlertMessage): void; + waitForAlert( + predicate: (alert: AlertMessage) => boolean, + timeoutMs?: number, + ): Promise; +} + // @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; + export namespace alert { + const mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function analytics(): MockAnalyticsApi & + MockWithApiFactory; + export namespace analytics { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function config(options?: { + data?: JsonObject; + }): MockConfigApi & MockWithApiFactory; + export namespace config { + const // (undocumented) + mock: (partialImpl?: Partial | undefined) => ApiMock; + } + export function discovery(options?: { + baseUrl?: string; + }): DiscoveryApi & MockWithApiFactory; + export namespace discovery { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function error( + options?: MockErrorApiOptions, + ): MockErrorApi & MockWithApiFactory; + export namespace error { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function featureFlags( + options?: MockFeatureFlagsApiOptions, + ): MockWithApiFactory; + export namespace featureFlags { + const mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function fetch( + options?: MockFetchApiOptions, + ): MockFetchApi & MockWithApiFactory; + export namespace fetch { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function identity(options?: { + userEntityRef?: string; + ownershipEntityRefs?: string[]; + token?: string; + email?: string; + displayName?: string; + picture?: string; + }): MockWithApiFactory; + export namespace identity { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function permission(options?: { + authorize?: + | AuthorizeResult.ALLOW + | AuthorizeResult.DENY + | (( + request: EvaluatePermissionRequest, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY); + }): MockPermissionApi & MockWithApiFactory; + export namespace permission { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function storage(options?: { + data?: JsonObject; + }): MockStorageApi & MockWithApiFactory; + export namespace storage { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function translation(): MockTranslationApi & + MockWithApiFactory; + export namespace translation { + const mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } +} -export { MockErrorApi }; +// @public +export class MockConfigApi implements ConfigApi { + constructor({ data }: { data: JsonObject }); + get(key?: string): T; + getBoolean(key: string): boolean; + getConfig(key: string): Config; + getConfigArray(key: string): Config[]; + getNumber(key: string): number; + getOptional(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; +} -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; + // (undocumented) + isActive(name: string): boolean; + // (undocumented) + registerFlag(flag: FeatureFlag): void; + // (undocumented) + save(options: FeatureFlagsSaveOptions): void; + setState(states: Record): void; +} -export { MockPermissionApi }; +// @public +export interface MockFeatureFlagsApiOptions { + initialStates?: Record; +} -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; + }; + resolvePluginProtocol?: + | undefined + | { + discoveryApi: Pick; + }; +} + +// @public +export class MockPermissionApi implements PermissionApi { + constructor( + requestHandler?: ( + request: EvaluatePermissionRequest, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY, + ); + // (undocumented) + authorize( + request: EvaluatePermissionRequest, + ): Promise; +} + +// @public +export class MockStorageApi implements StorageApi { + // (undocumented) + static create(data?: JsonObject): MockStorageApi; + // (undocumented) + forBucket(name: string): StorageApi; + // (undocumented) + observe$( + key: string, + ): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + // (undocumented) + snapshot(key: string): StorageValueSnapshot; +} + +// @public +export class MockTranslationApi implements TranslationApi { + // (undocumented) + static create(): MockTranslationApi; + // (undocumented) + getTranslation< + TMessages extends { + [key in string]: string; + }, + >( + translationRef: TranslationRef, + ): TranslationSnapshot; + // (undocumented) + translation$< + TMessages extends { + [key in string]: string; + }, + >(): Observable>; +} + +// @public +export type MockWithApiFactory = TApi & { + [mockApiFactorySymbol]: ApiFactory; +}; export { registerMswTestHooks }; // @public -export function renderInTestApp( +export function renderInTestApp( element: JSX.Element, options?: TestAppOptions, ): RenderResult; // @public -export function renderTestApp( - options: RenderTestAppOptions, -): RenderResult; +export function renderTestApp( + options?: RenderTestAppOptions, +): RenderResult; // @public export type RenderTestAppOptions = { @@ -152,37 +442,26 @@ export type RenderTestAppOptions = { }; // @public -export type TestApiPairs = TestApiProviderPropsApiPairs; +export type TestApiPair = + | readonly [ApiRef, TApi extends infer TImpl ? Partial : never] + | MockWithApiFactory; // @public -export const TestApiProvider: ( - props: TestApiProviderProps, -) => JSX_2.Element; +export type TestApiPairs = { + [TIndex in keyof TApiPairs]: TestApiPair; +}; + +// @public +export function TestApiProvider( + props: TestApiProviderProps, +): JSX.Element; // @public export type TestApiProviderProps = { - apis: readonly [...TestApiProviderPropsApiPairs]; + apis: readonly [...TestApiPairs]; children: ReactNode; }; -// @public -export type TestApiProviderPropsApiPair = TApi extends infer TImpl - ? readonly [ApiRef, Partial] - : never; - -// @public -export type TestApiProviderPropsApiPairs = { - [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; -}; - -// @public -export class TestApiRegistry implements ApiHolder { - static from( - ...apis: readonly [...TestApiProviderPropsApiPairs] - ): TestApiRegistry; - get(api: ApiRef): T | undefined; -} - // @public export type TestAppOptions = { mountedRoutes?: { diff --git a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts new file mode 100644 index 0000000000..ddfbacd3b9 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts @@ -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(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' }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts new file mode 100644 index 0000000000..305a527b87 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts @@ -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 { + 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 { + const existing = this.alerts.find(predicate); + if (existing) { + return existing; + } + const observers = this.observers; + + return new Promise((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); + }); + } +} diff --git a/packages/frontend-test-utils/src/apis/AlertApi/index.ts b/packages/frontend-test-utils/src/apis/AlertApi/index.ts new file mode 100644 index 0000000000..919149e249 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/AlertApi/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts new file mode 100644 index 0000000000..c3157a8aff --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts @@ -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'", + ); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts new file mode 100644 index 0000000000..349eee0d3a --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts @@ -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( + * + * + * , + * ); + * ``` + */ +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(key?: string): T { + return this.config.get(key); + } + /** {@inheritdoc @backstage/config#Config.getOptional} */ + getOptional(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); + } +} diff --git a/packages/frontend-test-utils/src/apis/ConfigApi/index.ts b/packages/frontend-test-utils/src/apis/ConfigApi/index.ts new file mode 100644 index 0000000000..6418899f79 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ConfigApi/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.test.ts b/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.test.ts new file mode 100644 index 0000000000..1fbfb576bd --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.test.ts @@ -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(promise: Promise): Promise { + 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', + ); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.ts b/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.ts new file mode 100644 index 0000000000..c7c8556355 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.ts @@ -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(); + private readonly waiters = new Set(); + + 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 { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('Timed out waiting for error')); + }, timeoutMs); + + this.waiters.add({ resolve, pattern }); + }); + } +} diff --git a/packages/frontend-test-utils/src/apis/ErrorApi/index.ts b/packages/frontend-test-utils/src/apis/ErrorApi/index.ts new file mode 100644 index 0000000000..1273a91531 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ErrorApi/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.test.ts b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.test.ts new file mode 100644 index 0000000000..0798fb48fd --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.test.ts @@ -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({}); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts new file mode 100644 index 0000000000..5421687c65 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts @@ -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; +} + +/** + * 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; + + 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 { + return Object.fromEntries(this.states); + } + + /** + * Set the state of multiple feature flags. + */ + setState(states: Record): void { + for (const [name, state] of Object.entries(states)) { + this.states.set(name, state); + } + } + + /** + * Clear all feature flag states. + */ + clearState(): void { + this.states.clear(); + } +} diff --git a/packages/frontend-test-utils/src/apis/FeatureFlagsApi/index.ts b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/index.ts new file mode 100644 index 0000000000..370e9f3b88 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts new file mode 100644 index 0000000000..ae4486d38f --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts @@ -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', + ); + }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts new file mode 100644 index 0000000000..b61ca680d1 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts @@ -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 }; + + /** + * 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 }; +} + +/** + * 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 = + 'token' in options + ? { getCredentials: async () => ({ token: options.token }) } + : options.identityApi; + + return FetchMiddlewares.injectIdentityAuth({ + identityApi: identityApi as IdentityApi, + allowUrl: () => true, + }); +} diff --git a/packages/frontend-test-utils/src/apis/FetchApi/index.ts b/packages/frontend-test-utils/src/apis/FetchApi/index.ts new file mode 100644 index 0000000000..5ac0c08ff1 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FetchApi/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts b/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts new file mode 100644 index 0000000000..55bb7653e5 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts @@ -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 & { + [mockApiFactorySymbol]: ApiFactory; +}; + +/** + * Helper to attach mock API metadata to an instance. + * + * @internal + */ +export function mockWithApiFactory( + apiRef: ApiRef, + implementation: TImpl, +): TImpl & { [mockApiFactorySymbol]: ApiFactory } { + const marked = implementation as TImpl & { + [mockApiFactorySymbol]: ApiFactory; + }; + (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 + * + * ``` + */ +export function attachMockApiFactory( + apiRef: ApiRef, + implementation: TImpl, +): TImpl & { [mockApiFactorySymbol]: ApiFactory } { + const marked = implementation as TImpl & { + [mockApiFactorySymbol]: ApiFactory; + }; + (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 | undefined { + if ( + typeof value === 'object' && + value !== null && + mockApiFactorySymbol in value && + typeof (value as any)[mockApiFactorySymbol] === 'object' + ) { + return (value as any)[mockApiFactorySymbol]; + } + return undefined; +} diff --git a/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.test.ts b/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.test.ts new file mode 100644 index 0000000000..b617d89cc4 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.test.ts @@ -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 }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.ts b/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.ts new file mode 100644 index 0000000000..c54a3993d3 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.ts @@ -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 { + return { result: this.requestHandler(request) }; + } +} diff --git a/packages/frontend-test-utils/src/apis/PermissionApi/index.ts b/packages/frontend-test-utils/src/apis/PermissionApi/index.ts new file mode 100644 index 0000000000..f69316b811 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/PermissionApi/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts new file mode 100644 index 0000000000..34908f561a --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts @@ -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(resolve => { + storage.observe$('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(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('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(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('test', { + foo: { + toJSON() { + return { + bar: 'baz', + }; + }, + }, + }); + + expect(storage.snapshot('test')).toMatchObject({ + presence: 'present', + value: { + foo: { + bar: 'baz', + }, + }, + }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts new file mode 100644 index 0000000000..b4cf762599 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts @@ -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; + + private constructor( + namespace: string, + bucketStorageApis: Map, + 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(key: string): StorageValueSnapshot { + 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(key: string, data: T): Promise { + 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 { + delete this.data[this.getKeyName(key)]; + this.notifyChanges({ + key, + presence: 'absent', + value: undefined, + }); + } + + observe$( + key: string, + ): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueSnapshot) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver> + >(); + + private readonly observable = new ObservableImpl< + StorageValueSnapshot + >(subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); +} diff --git a/packages/frontend-test-utils/src/apis/StorageApi/index.ts b/packages/frontend-test-utils/src/apis/StorageApi/index.ts new file mode 100644 index 0000000000..6e2ebc6918 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/TestApiProvider.tsx b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx new file mode 100644 index 0000000000..215e8c8a71 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx @@ -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 = + | readonly [ApiRef, TApi extends infer TImpl ? Partial : never] + | MockWithApiFactory; + +/** + * Represents an array of mock API implementation. + * @public + */ +export type TestApiPairs = { + [TIndex in keyof TApiPairs]: TestApiPair; +}; + +/** @internal */ +export function resolveTestApiEntries( + apis: readonly [...TestApiPairs], +): ApiHolder { + const apiMap = new Map(); + + 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]; + apiMap.set(apiRef.id, impl); + } + } + + return { + get: (ref: ApiRef) => apiMap.get(ref.id) as T | undefined, + }; +} + +/** + * Properties for the {@link TestApiProvider} component. + * + * @public + */ +export type TestApiProviderProps = { + apis: readonly [...TestApiPairs]; + 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( + * + * + * + * ); + * + * // Use with built-in mock APIs (no tuples needed) + * render( + * + * + * + * ); + * ``` + * + * @public + */ +export function TestApiProvider( + props: TestApiProviderProps, +): JSX.Element { + return ( + + ); +} diff --git a/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.test.tsx b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.test.tsx new file mode 100644 index 0000000000..4bee969a6d --- /dev/null +++ b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.test.tsx @@ -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: '
' })).toBe('Foo
'); + expect( + snapshot.t('foo', { + foo: '
', + 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:

hello

})).toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect(snapshot.t('jsx', { replace: { x:

hello

} })) + .toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect( + snapshot.t('jsxNested', { replace: { x: { y: { z:

hello

} } } }), + ).toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect(snapshot.t('jsxDeep', { x:

hello

})).toMatchInlineSnapshot(` + + <= +

+ hello +

+ > +
+ `); + }); + + 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'); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts new file mode 100644 index 0000000000..d5fa29ea73 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts @@ -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(); + + private constructor(i18n: I18n, interpolator: JsxInterpolator) { + this.#i18n = i18n; + this.#interpolator = interpolator; + } + + getTranslation( + translationRef: TranslationRef, + ): TranslationSnapshot { + 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( + this.#i18n.getFixedT(null, internalRef.id), + ); + + return { + ready: true, + t, + }; + } + + translation$(): Observable< + TranslationSnapshot + > { + // No need to implement, getTranslation will always return a ready snapshot + return new ObservableImpl>(_subscriber => { + return () => {}; + }); + } +} diff --git a/packages/frontend-test-utils/src/utils/index.ts b/packages/frontend-test-utils/src/apis/TranslationApi/index.ts similarity index 72% rename from packages/frontend-test-utils/src/utils/index.ts rename to packages/frontend-test-utils/src/apis/TranslationApi/index.ts index 2f6bfb5f9c..2c10347545 100644 --- a/packages/frontend-test-utils/src/utils/index.ts +++ b/packages/frontend-test-utils/src/apis/TranslationApi/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/createApiMock.test.ts b/packages/frontend-test-utils/src/apis/createApiMock.test.ts new file mode 100644 index 0000000000..8b03b6df75 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/createApiMock.test.ts @@ -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({ 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); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/createApiMock.ts b/packages/frontend-test-utils/src/apis/createApiMock.ts new file mode 100644 index 0000000000..759a8a1616 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/createApiMock.ts @@ -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 = { + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : 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( + apiRef: ApiRef, + mockFactory: () => jest.Mocked, +): (partialImpl?: Partial) => ApiMock { + 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; + }; +} diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index e5a0786cd0..6e09833298 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/apis/mockApis.test.ts b/packages/frontend-test-utils/src/apis/mockApis.test.ts new file mode 100644 index 0000000000..9c6310ca9c --- /dev/null +++ b/packages/frontend-test-utils/src/apis/mockApis.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts new file mode 100644 index 0000000000..9e3dce1e3a --- /dev/null +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -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 { + const instance = new MockAlertApi(); + return mockWithApiFactory( + alertApiRef, + instance, + ) as MockWithApiFactory; + } + /** + * 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 { + const instance = new MockFeatureFlagsApi(options); + return mockWithApiFactory( + featureFlagsApiRef, + instance, + ) as MockWithApiFactory; + } + /** + * 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 { + const instance = new MockAnalyticsApi(); + return mockWithApiFactory(analyticsApiRef, instance) as MockAnalyticsApi & + MockWithApiFactory; + } + + /** + * 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 { + const instance = MockTranslationApi.create(); + return mockWithApiFactory( + translationApiRef, + instance, + ) as MockTranslationApi & MockWithApiFactory; + } + + /** + * 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 { + const instance = new MockConfigApi({ data: options?.data ?? {} }); + return mockWithApiFactory(configApiRef, instance) as MockConfigApi & + MockWithApiFactory; + } + + /** + * 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 { + 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; + } + + /** + * 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 { + 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; + } + + /** + * 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 { + 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; + } + + /** + * 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 { + const instance = MockStorageApi.create(options?.data); + return mockWithApiFactory(storageApiRef, instance) as MockStorageApi & + MockWithApiFactory; + } + + /** + * 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 { + const instance = new MockErrorApi(options); + return mockWithApiFactory(errorApiRef, instance) as MockErrorApi & + MockWithApiFactory; + } + + /** + * 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 { + const instance = new MockFetchApi(options); + return mockWithApiFactory(fetchApiRef, instance) as MockFetchApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api#FetchApi}. + * + * @public + */ + export namespace fetch { + export const mock = createApiMock(fetchApiRef, () => ({ + fetch: jest.fn(), + })); + } +} diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index 8293872e29..9ce1863186 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx @@ -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(); diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 91a7bf9022..3a373b3b3a 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -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 { /** @internal */ static forSubject< T extends ExtensionDefinitionParameters, - TApiPairs extends any[], + const TApiPairs extends any[], >( subject: ExtensionDefinition, options?: { @@ -281,9 +281,7 @@ export class ExtensionTester { collector, ); - const apiHolder = this.#apis - ? TestApiRegistry.from(...this.#apis) - : TestApiRegistry.from(); + const apiHolder = resolveTestApiEntries(this.#apis ?? []); instantiateAppNodeTree(tree.root, apiHolder, collector); diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx index 334722f38a..c9d90715f6 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx @@ -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( - + , ); @@ -94,10 +91,10 @@ describe('renderInTestApp', () => { ); }; - const analyticsApiMock = new MockAnalyticsApi(); + const analyticsApiMock = mockApis.analytics(); renderInTestApp(, { - apis: [[analyticsApiRef, analyticsApiMock]], + apis: [analyticsApiMock], }); fireEvent.click(screen.getByRole('button', { name: 'Click me' })); diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index bcc6e5948e..10b36b68b4 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -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 = { * * @example * ```ts - * import { identityApiRef } from '@backstage/frontend-plugin-api'; * import { mockApis } from '@backstage/frontend-test-utils'; * * renderInTestApp(, { - * 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( +export function renderInTestApp( element: JSX.Element, options?: TestAppOptions, ): RenderResult { @@ -241,9 +242,14 @@ export function renderInTestApp( }, ]), __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]; + return createApiFactory(apiRef, implementation); + }), }, } as CreateSpecializedAppInternalOptions); diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx index b83ae7832a..858ad462f4 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -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 = { * * @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( - options: RenderTestAppOptions, -) { - const extensions = [...(options.extensions ?? [])]; +export function renderTestApp( + options?: RenderTestAppOptions, +): 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( params: { component: ({ children }) => ( ( appPluginOverride, ]; - if (options.features) { + if (options?.features) { features.push(...options.features); } @@ -183,9 +184,14 @@ export function renderTestApp( }, ]), __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]; + return createApiFactory(apiRef, implementation); + }), }, } as CreateSpecializedAppInternalOptions); diff --git a/packages/frontend-test-utils/src/index.ts b/packages/frontend-test-utils/src/index.ts index b709e7ac03..b4e7a4e007 100644 --- a/packages/frontend-test-utils/src/index.ts +++ b/packages/frontend-test-utils/src/index.ts @@ -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'; diff --git a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx deleted file mode 100644 index 97529033d0..0000000000 --- a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx +++ /dev/null @@ -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 extends infer TImpl - ? readonly [ApiRef, Partial] - : never; - -/** - * Helper type for representing an array of API reference pairs. - * @public - */ -export type TestApiProviderPropsApiPairs = { - [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; -}; - -/** - * Shorter alias for TestApiProviderPropsApiPairs for use in function signatures. - * @public - */ -export type TestApiPairs = TestApiProviderPropsApiPairs; - -/** - * Properties for the {@link TestApiProvider} component. - * - * @public - */ -export type TestApiProviderProps = { - apis: readonly [...TestApiProviderPropsApiPairs]; - 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( - ...apis: readonly [...TestApiProviderPropsApiPairs] - ) { - return new TestApiRegistry( - new Map(apis.map(([api, impl]) => [api.id, impl])), - ); - } - - private constructor(private readonly apis: Map) {} - - /** - * Returns an implementation of the API. - * - * @public - */ - get(api: ApiRef): 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( - * - * - * - * ); - * ``` - * - * @public - */ -export const TestApiProvider = ( - props: TestApiProviderProps, -) => { - return ( - - ); -}; diff --git a/packages/repo-tools/src/commands/type-deps/type-deps.ts b/packages/repo-tools/src/commands/type-deps/type-deps.ts index d6049f347c..80d30a41a8 100644 --- a/packages/repo-tools/src/commands/type-deps/type-deps.ts +++ b/packages/repo-tools/src/commands/type-deps/type-deps.ts @@ -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]), + ); } /** diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f6127ee5c7..591cb149e8 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -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 } diff --git a/plugins/catalog-graph/src/alpha.test.tsx b/plugins/catalog-graph/src/alpha.test.tsx index 277f6bcc28..e0241ec7b0 100644 --- a/plugins/catalog-graph/src/alpha.test.tsx +++ b/plugins/catalog-graph/src/alpha.test.tsx @@ -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()], ], }); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx index 4349d7005a..b8822de1c1 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx @@ -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('', () => { it('should not explode while loading', async () => { const { baseElement } = await renderWithEffects( - + {}} /> - , + , ); expect(baseElement).toBeInTheDocument(); }); it('should render current value', async () => { await renderWithEffects( - + {}} /> - , + , ); expect(screen.getByText('API')).toBeInTheDocument(); @@ -71,9 +64,9 @@ describe('', () => { it('should select value', async () => { const onChange = jest.fn(); await renderWithEffects( - + - , + , ); await userEvent.click(screen.getByLabelText('Open')); @@ -89,12 +82,12 @@ describe('', () => { it('should return undefined if all values are selected', async () => { const onChange = jest.fn(); await renderWithEffects( - + - , + , ); await userEvent.click(screen.getByLabelText('Open')); @@ -112,9 +105,9 @@ describe('', () => { it('should return all values when cleared', async () => { const onChange = jest.fn(); await renderWithEffects( - + - , + , ); await userEvent.click(screen.getByRole('combobox')); diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 5d63046df2..908a0c470e 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -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 } diff --git a/plugins/catalog-react/report-testUtils.api.md b/plugins/catalog-react/report-testUtils.api.md index 0f7b687965..c5c5300761 100644 --- a/plugins/catalog-react/report-testUtils.api.md +++ b/plugins/catalog-react/report-testUtils.api.md @@ -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; // @public export namespace catalogApiMock { diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index b2de7522b6..9fa8e6e097 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -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('', () => { } as GetEntityFacetsResponse), }, ], - [ - alertApiRef, - { - post: jest.fn(), - }, - ], + [alertApiRef, mockApis.alert()], ); it('renders available entity kinds', async () => { diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx index 281b94251e..58e731f197 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -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('', () => { diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx index 595cd70fc4..eea5f95e89 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx @@ -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', + }), + ); }); }); }); diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index 711fb09f8c..a5fda1bf5b 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -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() }], ]} diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts index a37d2669ad..007345de22 100644 --- a/plugins/catalog-react/src/testUtils/catalogApiMock.ts +++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts @@ -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( - ref: ApiRef, - mockFactory: () => jest.Mocked, -): (partialImpl?: Partial) => ApiMock { - 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; - }; -} +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( * * @public */ -export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi { - return new InMemoryCatalogClient(options); +export function catalogApiMock(options?: { + entities?: Entity[]; +}): MockWithApiFactory { + 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(), diff --git a/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx b/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx index 2b15ae5fc4..4fa8465797 100644 --- a/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx +++ b/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx @@ -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 diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx index 9ae448a9fc..c43db8746c 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.test.tsx @@ -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()], ); diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx index 80d97a2e1e..40085d195d 100644 --- a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx @@ -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 = { - 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' }), + ); }); }); }); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 66affd86f1..cb22475be6 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -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 }) => ( - - {children} - + {children} ); describe('EntitySwitch', () => { diff --git a/plugins/org/src/alpha.test.tsx b/plugins/org/src/alpha.test.tsx index d643ec79ee..1a42f36e54 100644 --- a/plugins/org/src/alpha.test.tsx +++ b/plugins/org/src/alpha.test.tsx @@ -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( diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index 840f210c78..077824950b 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -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:^", diff --git a/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx b/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx index cfbe8a40b4..9489321854 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx @@ -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( , ); - expect(mockAlertApi.post).toHaveBeenCalledWith({ + expect(mockAlertApi.getAlerts().length).toBeGreaterThanOrEqual(1); + expect(mockAlertApi.getAlerts()[0]).toMatchObject({ message: expect.stringContaining('something broked'), severity: 'error', }); diff --git a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx index 78c2064c4b..293ef54bc2 100644 --- a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx +++ b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx @@ -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', () => { diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx index dff5500b51..b51e3ff147 100644 --- a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx @@ -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<{}>) => ( - false }]]} - > + {children} ), @@ -119,7 +121,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ]} > {children} @@ -163,7 +167,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( true }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => true) }), + ]} > {children} @@ -214,7 +220,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ]} > {children} @@ -252,7 +260,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ]} > {children} @@ -356,7 +366,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ]} > {children} diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 9b505fc2b6..c08f507f42 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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:^", diff --git a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx index 5968f1da4f..cc45547827 100644 --- a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx +++ b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx @@ -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('', () => { diff --git a/yarn.lock b/yarn.lock index d9ff91af82..0544682600 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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