Add MockAlertApi and MockFeatureFlagsApi to frontend-test-utils
Introduces comprehensive mock implementations for AlertApi and FeatureFlagsApi in @backstage/frontend-test-utils, following the 3-pattern approach (function, factory, mock) for consistency with existing test utilities. The new mocks include useful testing methods: - MockAlertApi: clearAlerts(), waitForAlert(), getAlerts() - MockFeatureFlagsApi: getState(), setState(), clearState() Also adds a mockApis namespace that provides these new mocks and re-exports existing mockApis from @backstage/test-utils for backwards compatibility. Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -39,12 +39,14 @@
|
||||
"@backstage/test-utils": "workspace:^",
|
||||
"@backstage/types": "workspace:^",
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"zen-observable": "^0.10.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "workspace:^",
|
||||
"@testing-library/jest-dom": "^6.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/zen-observable": "^0.8.0",
|
||||
"react": "^18.0.2",
|
||||
"react-dom": "^18.0.2",
|
||||
"react-router-dom": "^6.30.2"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
> 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';
|
||||
@@ -14,10 +16,14 @@ import { ErrorWithContext } from '@backstage/test-utils';
|
||||
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 { FrontendFeature } from '@backstage/frontend-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JSX as JSX_2 } from 'react/jsx-runtime';
|
||||
import { mockApis } from '@backstage/test-utils';
|
||||
import { mockApis as mockApis_2 } from '@backstage/test-utils';
|
||||
import { MockConfigApi } from '@backstage/test-utils';
|
||||
import { MockErrorApi } from '@backstage/test-utils';
|
||||
import { MockErrorApiOptions } from '@backstage/test-utils';
|
||||
@@ -26,6 +32,7 @@ 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 { Observable } from '@backstage/types';
|
||||
import { ReactNode } from 'react';
|
||||
import { registerMswTestHooks } from '@backstage/test-utils';
|
||||
import { RenderResult } from '@testing-library/react';
|
||||
@@ -100,6 +107,20 @@ export class ExtensionTester<UOutput extends ExtensionDataRef> {
|
||||
snapshot(): ExtensionSnapshotNode;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class MockAlertApi implements AlertApi {
|
||||
// (undocumented)
|
||||
alert$(): Observable<AlertMessage>;
|
||||
clearAlerts(): void;
|
||||
getAlerts(): AlertMessage[];
|
||||
// (undocumented)
|
||||
post(alert: AlertMessage): void;
|
||||
waitForAlert(
|
||||
predicate: (alert: AlertMessage) => boolean,
|
||||
timeoutMs?: number,
|
||||
): Promise<AlertMessage>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export class MockAnalyticsApi implements AnalyticsApi {
|
||||
// (undocumented)
|
||||
@@ -108,7 +129,59 @@ export class MockAnalyticsApi implements AnalyticsApi {
|
||||
getEvents(): AnalyticsEvent[];
|
||||
}
|
||||
|
||||
export { mockApis };
|
||||
// @public
|
||||
export namespace mockApis {
|
||||
export function alert(): MockAlertApi;
|
||||
export namespace alert {
|
||||
const factory: () => any;
|
||||
const mock: (
|
||||
partialImpl?:
|
||||
| Partial<{
|
||||
post: jest.Mock<any, any, any>;
|
||||
alert$: jest.Mock<any, any, any>;
|
||||
}>
|
||||
| undefined,
|
||||
) => ApiMock<{
|
||||
post: jest.Mock<any, any, any>;
|
||||
alert$: jest.Mock<any, any, any>;
|
||||
}>;
|
||||
}
|
||||
export function featureFlags(
|
||||
options?: MockFeatureFlagsApiOptions,
|
||||
): MockFeatureFlagsApi;
|
||||
export namespace featureFlags {
|
||||
const factory: (options?: MockFeatureFlagsApiOptions | undefined) => any;
|
||||
const mock: (
|
||||
partialImpl?:
|
||||
| Partial<{
|
||||
registerFlag: jest.Mock<any, any, any>;
|
||||
getRegisteredFlags: jest.Mock<any, any, any>;
|
||||
isActive: jest.Mock<any, any, any>;
|
||||
save: jest.Mock<any, any, any>;
|
||||
}>
|
||||
| undefined,
|
||||
) => ApiMock<{
|
||||
registerFlag: jest.Mock<any, any, any>;
|
||||
getRegisteredFlags: jest.Mock<any, any, any>;
|
||||
isActive: jest.Mock<any, any, any>;
|
||||
save: jest.Mock<any, any, any>;
|
||||
}>;
|
||||
}
|
||||
const // (undocumented)
|
||||
analytics: typeof mockApis_2.analytics;
|
||||
const // (undocumented)
|
||||
config: typeof mockApis_2.config;
|
||||
const // (undocumented)
|
||||
discovery: typeof mockApis_2.discovery;
|
||||
const // (undocumented)
|
||||
identity: typeof mockApis_2.identity;
|
||||
const // (undocumented)
|
||||
permission: typeof mockApis_2.permission;
|
||||
const // (undocumented)
|
||||
storage: typeof mockApis_2.storage;
|
||||
const // (undocumented)
|
||||
translation: typeof mockApis_2.translation;
|
||||
}
|
||||
|
||||
export { MockConfigApi };
|
||||
|
||||
@@ -116,6 +189,27 @@ export { MockErrorApi };
|
||||
|
||||
export { MockErrorApiOptions };
|
||||
|
||||
// @public
|
||||
export class MockFeatureFlagsApi implements FeatureFlagsApi {
|
||||
constructor(options?: MockFeatureFlagsApiOptions);
|
||||
clearState(): void;
|
||||
// (undocumented)
|
||||
getRegisteredFlags(): FeatureFlag[];
|
||||
getState(): Record<string, FeatureFlagState>;
|
||||
// (undocumented)
|
||||
isActive(name: string): boolean;
|
||||
// (undocumented)
|
||||
registerFlag(flag: FeatureFlag): void;
|
||||
// (undocumented)
|
||||
save(options: FeatureFlagsSaveOptions): void;
|
||||
setState(states: Record<string, FeatureFlagState>): void;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface MockFeatureFlagsApiOptions {
|
||||
initialStates?: Record<string, FeatureFlagState>;
|
||||
}
|
||||
|
||||
export { MockFetchApi };
|
||||
|
||||
export { MockFetchApiOptions };
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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', done => {
|
||||
const api = new MockAlertApi();
|
||||
const messages: string[] = [];
|
||||
|
||||
api.alert$().subscribe({
|
||||
next: alert => {
|
||||
messages.push(alert.message);
|
||||
if (messages.length === 2) {
|
||||
expect(messages).toEqual(['First', 'Second']);
|
||||
done();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
api.post({ message: 'First' });
|
||||
api.post({ message: 'Second' });
|
||||
});
|
||||
|
||||
it('should wait for matching alert', async () => {
|
||||
const api = new MockAlertApi();
|
||||
|
||||
setTimeout(() => {
|
||||
api.post({ message: 'Wrong alert' });
|
||||
api.post({ message: 'Right alert', severity: 'error' });
|
||||
}, 10);
|
||||
|
||||
const alert = await api.waitForAlert(
|
||||
a => a.message === 'Right alert',
|
||||
1000,
|
||||
);
|
||||
|
||||
expect(alert).toMatchObject({ message: 'Right alert', severity: 'error' });
|
||||
});
|
||||
|
||||
it('should timeout if alert never appears', async () => {
|
||||
const api = new MockAlertApi();
|
||||
|
||||
await expect(
|
||||
api.waitForAlert(a => a.message === 'Never posted', 100),
|
||||
).rejects.toThrow('Timed out waiting for alert');
|
||||
});
|
||||
|
||||
it('should resolve immediately if alert already exists', async () => {
|
||||
const api = new MockAlertApi();
|
||||
|
||||
api.post({ message: 'Already posted' });
|
||||
|
||||
const alert = await api.waitForAlert(
|
||||
a => a.message === 'Already posted',
|
||||
1000,
|
||||
);
|
||||
|
||||
expect(alert).toMatchObject({ message: 'Already posted' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AlertMessage } from '@backstage/frontend-plugin-api';
|
||||
import { AlertApi } from '@backstage/frontend-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link @backstage/frontend-plugin-api#AlertApi} for testing alert behavior.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
* ```ts
|
||||
* const alertApi = new MockAlertApi();
|
||||
* alertApi.post({ message: 'Test alert' });
|
||||
* expect(alertApi.getAlerts()).toHaveLength(1);
|
||||
* ```
|
||||
*/
|
||||
export class MockAlertApi implements AlertApi {
|
||||
private alerts: AlertMessage[] = [];
|
||||
private observers = new Set<(alert: AlertMessage) => void>();
|
||||
|
||||
post(alert: AlertMessage) {
|
||||
this.alerts.push(alert);
|
||||
this.observers.forEach(observer => observer(alert));
|
||||
}
|
||||
|
||||
alert$(): Observable<AlertMessage> {
|
||||
return new ObservableImpl(subscriber => {
|
||||
const observer = (alert: AlertMessage) => {
|
||||
subscriber.next(alert);
|
||||
};
|
||||
this.observers.add(observer);
|
||||
return () => {
|
||||
this.observers.delete(observer);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all alerts that have been posted.
|
||||
*/
|
||||
getAlerts(): AlertMessage[] {
|
||||
return this.alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all collected alerts.
|
||||
*/
|
||||
clearAlerts(): void {
|
||||
this.alerts = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an alert matching the given predicate.
|
||||
*
|
||||
* @param predicate - Function to test each alert
|
||||
* @param timeoutMs - Maximum time to wait in milliseconds
|
||||
* @returns Promise that resolves with the matching alert
|
||||
*/
|
||||
async waitForAlert(
|
||||
predicate: (alert: AlertMessage) => boolean,
|
||||
timeoutMs: number = 2000,
|
||||
): Promise<AlertMessage> {
|
||||
const existing = this.alerts.find(predicate);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const observers = this.observers;
|
||||
|
||||
return new Promise<AlertMessage>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
observers.delete(observer);
|
||||
reject(new Error('Timed out waiting for alert'));
|
||||
}, timeoutMs);
|
||||
|
||||
function observer(alert: AlertMessage) {
|
||||
if (predicate(alert)) {
|
||||
clearTimeout(timeoutId);
|
||||
observers.delete(observer);
|
||||
resolve(alert);
|
||||
}
|
||||
}
|
||||
|
||||
observers.add(observer);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockAlertApi } from './MockAlertApi';
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
|
||||
import { MockFeatureFlagsApi } from './MockFeatureFlagsApi';
|
||||
|
||||
describe('MockFeatureFlagsApi', () => {
|
||||
it('should register flags', () => {
|
||||
const api = new MockFeatureFlagsApi();
|
||||
|
||||
api.registerFlag({
|
||||
name: 'test-flag-1',
|
||||
pluginId: 'test-plugin',
|
||||
description: 'Test flag 1',
|
||||
});
|
||||
|
||||
api.registerFlag({
|
||||
name: 'test-flag-2',
|
||||
pluginId: 'test-plugin',
|
||||
description: 'Test flag 2',
|
||||
});
|
||||
|
||||
expect(api.getRegisteredFlags()).toHaveLength(2);
|
||||
expect(api.getRegisteredFlags()[0].name).toBe('test-flag-1');
|
||||
});
|
||||
|
||||
it('should not register duplicate flags', () => {
|
||||
const api = new MockFeatureFlagsApi();
|
||||
|
||||
api.registerFlag({
|
||||
name: 'test-flag',
|
||||
pluginId: 'test-plugin',
|
||||
description: 'Test flag',
|
||||
});
|
||||
|
||||
api.registerFlag({
|
||||
name: 'test-flag',
|
||||
pluginId: 'test-plugin',
|
||||
description: 'Test flag duplicate',
|
||||
});
|
||||
|
||||
expect(api.getRegisteredFlags()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle feature flag states', () => {
|
||||
const api = new MockFeatureFlagsApi();
|
||||
|
||||
expect(api.isActive('test-flag')).toBe(false);
|
||||
|
||||
api.save({ states: { 'test-flag': FeatureFlagState.Active } });
|
||||
expect(api.isActive('test-flag')).toBe(true);
|
||||
|
||||
api.save({ states: { 'test-flag': FeatureFlagState.None } });
|
||||
expect(api.isActive('test-flag')).toBe(false);
|
||||
});
|
||||
|
||||
it('should initialize with states', () => {
|
||||
const api = new MockFeatureFlagsApi({
|
||||
initialStates: {
|
||||
'flag-1': FeatureFlagState.Active,
|
||||
'flag-2': FeatureFlagState.None,
|
||||
},
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
expect(api.isActive('flag-2')).toBe(false);
|
||||
});
|
||||
|
||||
it('should save and replace states', () => {
|
||||
const api = new MockFeatureFlagsApi({
|
||||
initialStates: { 'flag-1': FeatureFlagState.Active },
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
|
||||
api.save({
|
||||
states: {
|
||||
'flag-2': FeatureFlagState.Active,
|
||||
},
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(false);
|
||||
expect(api.isActive('flag-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should save and merge states', () => {
|
||||
const api = new MockFeatureFlagsApi({
|
||||
initialStates: { 'flag-1': FeatureFlagState.Active },
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
|
||||
api.save({
|
||||
states: {
|
||||
'flag-2': FeatureFlagState.Active,
|
||||
},
|
||||
merge: true,
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
expect(api.isActive('flag-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should get and set state', () => {
|
||||
const api = new MockFeatureFlagsApi();
|
||||
|
||||
api.setState({
|
||||
'flag-1': FeatureFlagState.Active,
|
||||
'flag-2': FeatureFlagState.None,
|
||||
});
|
||||
|
||||
const state = api.getState();
|
||||
expect(state).toEqual({
|
||||
'flag-1': FeatureFlagState.Active,
|
||||
'flag-2': FeatureFlagState.None,
|
||||
});
|
||||
});
|
||||
|
||||
it('should clear state', () => {
|
||||
const api = new MockFeatureFlagsApi({
|
||||
initialStates: { 'flag-1': FeatureFlagState.Active },
|
||||
});
|
||||
|
||||
expect(api.isActive('flag-1')).toBe(true);
|
||||
|
||||
api.clearState();
|
||||
expect(api.isActive('flag-1')).toBe(false);
|
||||
expect(api.getState()).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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 {
|
||||
FeatureFlag,
|
||||
FeatureFlagsApi,
|
||||
FeatureFlagsSaveOptions,
|
||||
FeatureFlagState,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
/**
|
||||
* Options for configuring {@link MockFeatureFlagsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface MockFeatureFlagsApiOptions {
|
||||
/**
|
||||
* Initial feature flag states.
|
||||
*/
|
||||
initialStates?: Record<string, FeatureFlagState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi} for testing feature flag behavior.
|
||||
*
|
||||
* @public
|
||||
* @example
|
||||
* ```ts
|
||||
* const api = new MockFeatureFlagsApi({
|
||||
* initialStates: { 'my-feature': FeatureFlagState.Active }
|
||||
* });
|
||||
* expect(api.isActive('my-feature')).toBe(true);
|
||||
* ```
|
||||
*/
|
||||
export class MockFeatureFlagsApi implements FeatureFlagsApi {
|
||||
private registeredFlags: FeatureFlag[] = [];
|
||||
private states: Map<string, FeatureFlagState>;
|
||||
|
||||
constructor(options?: MockFeatureFlagsApiOptions) {
|
||||
this.states = new Map(Object.entries(options?.initialStates ?? {}));
|
||||
}
|
||||
|
||||
registerFlag(flag: FeatureFlag): void {
|
||||
if (!this.registeredFlags.some(f => f.name === flag.name)) {
|
||||
this.registeredFlags.push(flag);
|
||||
}
|
||||
}
|
||||
|
||||
getRegisteredFlags(): FeatureFlag[] {
|
||||
return this.registeredFlags;
|
||||
}
|
||||
|
||||
isActive(name: string): boolean {
|
||||
return this.states.get(name) === FeatureFlagState.Active;
|
||||
}
|
||||
|
||||
save(options: FeatureFlagsSaveOptions): void {
|
||||
if (options.merge) {
|
||||
for (const [name, state] of Object.entries(options.states)) {
|
||||
this.states.set(name, state);
|
||||
}
|
||||
} else {
|
||||
this.states = new Map(Object.entries(options.states));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of all feature flags as a record.
|
||||
*/
|
||||
getState(): Record<string, FeatureFlagState> {
|
||||
return Object.fromEntries(this.states);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of multiple feature flags.
|
||||
*/
|
||||
setState(states: Record<string, FeatureFlagState>): void {
|
||||
for (const [name, state] of Object.entries(states)) {
|
||||
this.states.set(name, state);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all feature flag states.
|
||||
*/
|
||||
clearState(): void {
|
||||
this.states.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { MockFeatureFlagsApi } from './MockFeatureFlagsApi';
|
||||
export type { MockFeatureFlagsApiOptions } from './MockFeatureFlagsApi';
|
||||
@@ -24,8 +24,18 @@ export {
|
||||
MockPermissionApi,
|
||||
MockStorageApi,
|
||||
type MockStorageBucket,
|
||||
mockApis,
|
||||
type ApiMock,
|
||||
} from '@backstage/test-utils';
|
||||
|
||||
export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi';
|
||||
export { mockApis } from './mockApis';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockAlertApi } from './AlertApi';
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type { MockFeatureFlagsApi, MockFeatureFlagsApiOptions } from './FeatureFlagsApi';
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlagState } from '@backstage/frontend-plugin-api';
|
||||
import { mockApis } from './mockApis';
|
||||
|
||||
describe('mockApis', () => {
|
||||
describe('alert', () => {
|
||||
it('can create an instance', () => {
|
||||
const alert = mockApis.alert();
|
||||
alert.post({ message: 'test alert' });
|
||||
expect(alert.getAlerts()).toHaveLength(1);
|
||||
expect(alert.getAlerts()[0]).toMatchObject({ message: 'test alert' });
|
||||
});
|
||||
|
||||
it('can clear alerts', () => {
|
||||
const alert = mockApis.alert();
|
||||
alert.post({ message: 'test' });
|
||||
expect(alert.getAlerts()).toHaveLength(1);
|
||||
alert.clearAlerts();
|
||||
expect(alert.getAlerts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('can create a mock and make assertions on it', () => {
|
||||
const alert = mockApis.alert.mock({
|
||||
post: jest.fn(msg => {
|
||||
expect(msg).toMatchObject({ message: 'test' });
|
||||
}),
|
||||
});
|
||||
alert.post({ message: 'test' });
|
||||
expect(alert.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('featureFlags', () => {
|
||||
it('can create an instance', () => {
|
||||
const featureFlags = mockApis.featureFlags({
|
||||
initialStates: { 'test-flag': FeatureFlagState.Active },
|
||||
});
|
||||
expect(featureFlags.isActive('test-flag')).toBe(true);
|
||||
expect(featureFlags.isActive('other-flag')).toBe(false);
|
||||
});
|
||||
|
||||
it('can save and merge state', () => {
|
||||
const featureFlags = mockApis.featureFlags({
|
||||
initialStates: { 'flag-1': FeatureFlagState.Active },
|
||||
});
|
||||
|
||||
featureFlags.save({
|
||||
states: { 'flag-2': FeatureFlagState.Active },
|
||||
merge: true,
|
||||
});
|
||||
|
||||
expect(featureFlags.isActive('flag-1')).toBe(true);
|
||||
expect(featureFlags.isActive('flag-2')).toBe(true);
|
||||
});
|
||||
|
||||
it('can set and clear state using helper methods', () => {
|
||||
const featureFlags = mockApis.featureFlags();
|
||||
featureFlags.setState({ 'test-flag': FeatureFlagState.Active });
|
||||
expect(featureFlags.getState()).toEqual({
|
||||
'test-flag': FeatureFlagState.Active,
|
||||
});
|
||||
featureFlags.clearState();
|
||||
expect(featureFlags.getState()).toEqual({});
|
||||
});
|
||||
|
||||
it('can create a mock and make assertions on it', () => {
|
||||
const featureFlags = mockApis.featureFlags.mock({
|
||||
isActive: jest.fn(() => true),
|
||||
});
|
||||
expect(featureFlags.isActive('test')).toBe(true);
|
||||
expect(featureFlags.isActive).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-exported APIs from test-utils', () => {
|
||||
it('should have analytics', () => {
|
||||
expect(mockApis.analytics).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have config', () => {
|
||||
expect(mockApis.config).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have discovery', () => {
|
||||
expect(mockApis.discovery).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have identity', () => {
|
||||
expect(mockApis.identity).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have permission', () => {
|
||||
expect(mockApis.permission).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have storage', () => {
|
||||
expect(mockApis.storage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have translation', () => {
|
||||
expect(mockApis.translation).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* 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,
|
||||
createApiFactory,
|
||||
featureFlagsApiRef,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
mockApis as testUtilsMockApis,
|
||||
type ApiMock,
|
||||
} from '@backstage/test-utils';
|
||||
import { MockAlertApi } from './AlertApi';
|
||||
import {
|
||||
MockFeatureFlagsApi,
|
||||
MockFeatureFlagsApiOptions,
|
||||
} from './FeatureFlagsApi';
|
||||
|
||||
/** @internal */
|
||||
function simpleMock<TApi>(
|
||||
ref: any,
|
||||
mockFactory: () => jest.Mocked<TApi>,
|
||||
): (partialImpl?: Partial<TApi>) => ApiMock<TApi> {
|
||||
return partialImpl => {
|
||||
const mock = mockFactory();
|
||||
if (partialImpl) {
|
||||
for (const [key, impl] of Object.entries(partialImpl)) {
|
||||
if (typeof impl === 'function') {
|
||||
(mock as any)[key].mockImplementation(impl);
|
||||
} else {
|
||||
(mock as any)[key] = impl;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Object.assign(mock, {
|
||||
factory: createApiFactory({
|
||||
api: ref,
|
||||
deps: {},
|
||||
factory: () => mock,
|
||||
}),
|
||||
}) as ApiMock<TApi>;
|
||||
};
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
function simpleFactory<TApi, TArgs extends unknown[]>(
|
||||
ref: any,
|
||||
factory: (...args: TArgs) => TApi,
|
||||
): (...args: TArgs) => any {
|
||||
return (...args) =>
|
||||
createApiFactory({
|
||||
api: ref,
|
||||
deps: {},
|
||||
factory: () => factory(...args),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 three 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);
|
||||
* ```
|
||||
*
|
||||
* 3: Creating an API factory that behaves similarly to the mock as per above.
|
||||
*
|
||||
* ```ts
|
||||
* const factory = mockApis.foo.factory({
|
||||
* someMethod: () => 'mocked result',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
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(): MockAlertApi {
|
||||
return new MockAlertApi();
|
||||
}
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}.
|
||||
*
|
||||
* @see {@link @backstage/frontend-plugin-api#mockApis.alert}
|
||||
* @public
|
||||
*/
|
||||
export namespace alert {
|
||||
/**
|
||||
* Creates a factory for a fake implementation of
|
||||
* {@link @backstage/frontend-plugin-api#AlertApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const factory = simpleFactory(alertApiRef, 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 = simpleMock(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,
|
||||
): MockFeatureFlagsApi {
|
||||
return new MockFeatureFlagsApi(options);
|
||||
}
|
||||
/**
|
||||
* Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
|
||||
*
|
||||
* @see {@link @backstage/frontend-plugin-api#mockApis.featureFlags}
|
||||
* @public
|
||||
*/
|
||||
export namespace featureFlags {
|
||||
/**
|
||||
* Creates a factory for a fake implementation of
|
||||
* {@link @backstage/frontend-plugin-api#FeatureFlagsApi}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const factory = simpleFactory(featureFlagsApiRef, 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 = simpleMock(featureFlagsApiRef, () => ({
|
||||
registerFlag: jest.fn(),
|
||||
getRegisteredFlags: jest.fn(),
|
||||
isActive: jest.fn(),
|
||||
save: jest.fn(),
|
||||
}));
|
||||
}
|
||||
|
||||
// Re-export all mockApis from test-utils
|
||||
export const analytics = testUtilsMockApis.analytics;
|
||||
export const config = testUtilsMockApis.config;
|
||||
export const discovery = testUtilsMockApis.discovery;
|
||||
export const identity = testUtilsMockApis.identity;
|
||||
export const permission = testUtilsMockApis.permission;
|
||||
export const storage = testUtilsMockApis.storage;
|
||||
export const translation = testUtilsMockApis.translation;
|
||||
}
|
||||
@@ -3961,9 +3961,11 @@ __metadata:
|
||||
"@backstage/version-bridge": "workspace:^"
|
||||
"@testing-library/jest-dom": "npm:^6.0.0"
|
||||
"@types/react": "npm:^18.0.0"
|
||||
"@types/zen-observable": "npm:^0.8.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
|
||||
|
||||
Reference in New Issue
Block a user