From d2ac2ec49d19dc33b6e7638bf7122f7cd8a8a4eb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 4 Feb 2026 22:42:24 +0100 Subject: [PATCH] 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 --- .../frontend-mock-apis-alert-featureflags.md | 5 + packages/frontend-test-utils/package.json | 2 + packages/frontend-test-utils/report.api.md | 98 +++++++- .../src/apis/AlertApi/MockAlertApi.test.ts | 103 +++++++++ .../src/apis/AlertApi/MockAlertApi.ts | 102 +++++++++ .../src/apis/AlertApi/index.ts | 17 ++ .../MockFeatureFlagsApi.test.ts | 143 ++++++++++++ .../FeatureFlagsApi/MockFeatureFlagsApi.ts | 102 +++++++++ .../src/apis/FeatureFlagsApi/index.ts | 18 ++ .../frontend-test-utils/src/apis/index.ts | 12 +- .../src/apis/mockApis.test.ts | 119 ++++++++++ .../frontend-test-utils/src/apis/mockApis.ts | 209 ++++++++++++++++++ yarn.lock | 2 + 13 files changed, 929 insertions(+), 3 deletions(-) create mode 100644 .changeset/frontend-mock-apis-alert-featureflags.md create mode 100644 packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts create mode 100644 packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts create mode 100644 packages/frontend-test-utils/src/apis/AlertApi/index.ts create mode 100644 packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.test.ts create mode 100644 packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts create mode 100644 packages/frontend-test-utils/src/apis/FeatureFlagsApi/index.ts create mode 100644 packages/frontend-test-utils/src/apis/mockApis.test.ts create mode 100644 packages/frontend-test-utils/src/apis/mockApis.ts 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/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index f359a9d2b8..a0191817e0 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -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" diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 4d1a0764ed..4621f84387 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -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 { 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,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; + alert$: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + post: jest.Mock; + alert$: jest.Mock; + }>; + } + export function featureFlags( + options?: MockFeatureFlagsApiOptions, + ): MockFeatureFlagsApi; + export namespace featureFlags { + const factory: (options?: MockFeatureFlagsApiOptions | undefined) => any; + const mock: ( + partialImpl?: + | Partial<{ + registerFlag: jest.Mock; + getRegisteredFlags: jest.Mock; + isActive: jest.Mock; + save: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + registerFlag: jest.Mock; + getRegisteredFlags: jest.Mock; + isActive: jest.Mock; + save: jest.Mock; + }>; + } + 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; + // (undocumented) + isActive(name: string): boolean; + // (undocumented) + registerFlag(flag: FeatureFlag): void; + // (undocumented) + save(options: FeatureFlagsSaveOptions): void; + setState(states: Record): void; +} + +// @public +export interface MockFeatureFlagsApiOptions { + initialStates?: Record; +} + export { MockFetchApi }; export { MockFetchApiOptions }; 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..4e44d4281c --- /dev/null +++ b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts @@ -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' }); + }); +}); 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..d07d9602b2 --- /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 alerts: AlertMessage[] = []; + private 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 = []; + } + + /** + * 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/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..ef0eef3159 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.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 { + 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 registeredFlags: FeatureFlag[] = []; + private 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 = new Map(Object.entries(options.states)); + } + } + + /** + * 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/index.ts b/packages/frontend-test-utils/src/apis/index.ts index e5a0786cd0..09996209a6 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -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'; 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..1cc965c0b8 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -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( + ref: any, + 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; + }; +} + +/** @internal */ +function simpleFactory( + 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; +} diff --git a/yarn.lock b/yarn.lock index 8cbe70fc51..20a3bd3b48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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