From d2ac2ec49d19dc33b6e7638bf7122f7cd8a8a4eb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 4 Feb 2026 22:42:24 +0100 Subject: [PATCH 01/23] 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 From 36fb574fff8f545f3c711443b830269335f1b014 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Feb 2026 13:41:07 +0100 Subject: [PATCH 02/23] use new mockApis Signed-off-by: Patrik Oldsberg --- .../SelectedKindsFilter.test.tsx | 11 ++--- .../EntityKindPicker.test.tsx | 8 +--- .../EntityTypePicker.test.tsx | 8 +--- .../UnregisterEntityDialog.test.tsx | 28 +++++------- .../src/hooks/useEntityListProvider.test.tsx | 5 ++- .../EntityLayout/EntityLayout.test.tsx | 4 +- .../DeleteEntityDialog.test.tsx | 12 ++--- .../EntitySwitch/EntitySwitch.test.tsx | 4 +- plugins/scaffolder-react/package.json | 1 + .../TemplateCategoryPicker.test.tsx | 10 +++-- .../useFilteredSchemaProperties.test.tsx | 5 +-- .../src/next/hooks/useTemplateSchema.test.tsx | 44 +++++++++++++++---- plugins/scaffolder/package.json | 1 + .../TemplateTypePicker.test.tsx | 10 ++--- yarn.lock | 2 + 15 files changed, 83 insertions(+), 70 deletions(-) diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx index 4349d7005a..74c596b197 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx @@ -15,13 +15,10 @@ */ import { ApiProvider } from '@backstage/core-app-api'; -import { AlertApi, alertApiRef, errorApiRef } from '@backstage/core-plugin-api'; +import { alertApiRef, errorApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { - mockApis, - renderWithEffects, - TestApiRegistry, -} from '@backstage/test-utils'; +import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SelectedKindsFilter } from './SelectedKindsFilter'; @@ -42,7 +39,7 @@ const catalogApi = catalogApiMock.mock({ }); const apis = TestApiRegistry.from( [catalogApiRef, catalogApi], - [alertApiRef, {} as AlertApi], + [alertApiRef, mockApis.alert()], [translationApiRef, mockApis.translation()], [errorApiRef, { post: jest.fn() }], ); 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/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..6d5df6fb10 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -24,10 +24,10 @@ 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 { mockApis } from '@backstage/frontend-test-utils'; -const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); +const mockFeatureFlagsApi = mockApis.featureFlags.mock(); const Wrapper = ({ children }: { children?: ReactNode }) => ( {children} 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..b2cf94edbe 100644 --- a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx @@ -16,6 +16,7 @@ import { useTemplateSchema } from './useTemplateSchema'; import { renderHook } from '@testing-library/react'; import { TestApiProvider } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { PropsWithChildren } from 'react'; import { featureFlagsApiRef } from '@backstage/core-plugin-api'; import { TemplateParameterSchema } from '../../types'; @@ -49,11 +50,13 @@ describe('useTemplateSchema', () => { ], }; + const mockFeatureFlagsApi = mockApis.featureFlags.mock({ + isActive: jest.fn(() => false), + }); + const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( - false }]]} - > + {children} ), @@ -119,7 +122,12 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + [ + featureFlagsApiRef, + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ], + ]} > {children} @@ -163,7 +171,12 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( true }]]} + apis={[ + [ + featureFlagsApiRef, + mockApis.featureFlags.mock({ isActive: jest.fn(() => true) }), + ], + ]} > {children} @@ -214,7 +227,12 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + [ + featureFlagsApiRef, + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ], + ]} > {children} @@ -252,7 +270,12 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + [ + featureFlagsApiRef, + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ], + ]} > {children} @@ -356,7 +379,12 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + [ + featureFlagsApiRef, + 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 20a3bd3b48..f00bf67f21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7037,6 +7037,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:^" @@ -7106,6 +7107,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:^" From a20cbb2514db29545e9b9f03f004423f2e0a5e28 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Feb 2026 21:41:41 +0100 Subject: [PATCH 03/23] frontend-test-utils: new utility for passing mock APIs directly Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/report.api.md | 40 ++++++-- .../frontend-test-utils/src/apis/ApiMock.ts | 34 +++++++ .../frontend-test-utils/src/apis/index.ts | 12 ++- .../frontend-test-utils/src/apis/mockApis.ts | 43 +++++--- .../frontend-test-utils/src/apis/utils.ts | 97 +++++++++++++++++++ .../src/utils/TestApiProvider.tsx | 68 +++++++++++-- .../frontend-test-utils/src/utils/index.ts | 1 + .../SelectedKindsFilter.test.tsx | 11 +-- .../EntitySwitch/EntitySwitch.test.tsx | 9 +- 9 files changed, 269 insertions(+), 46 deletions(-) create mode 100644 packages/frontend-test-utils/src/apis/ApiMock.ts create mode 100644 packages/frontend-test-utils/src/apis/utils.ts diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 4621f84387..7c5f0fcd87 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -7,8 +7,8 @@ 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 { ApiFactory } from '@backstage/frontend-plugin-api'; import { ApiHolder } from '@backstage/frontend-plugin-api'; -import { ApiMock } from '@backstage/test-utils'; import { ApiRef } from '@backstage/frontend-plugin-api'; import { AppNode } from '@backstage/frontend-plugin-api'; import { AppNodeInstance } from '@backstage/frontend-plugin-api'; @@ -40,7 +40,15 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; import { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries'; import { withLogCollector } from '@backstage/test-utils'; -export { ApiMock }; +// @public +export type ApiMock = { + factory: ApiFactory; + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; // @public (undocumented) export function createExtensionTester< @@ -129,9 +137,15 @@ export class MockAnalyticsApi implements AnalyticsApi { getEvents(): AnalyticsEvent[]; } +// @public +export type MockApiFactorySymbol = typeof mockApiFactorySymbol; + +// @public +export const mockApiFactorySymbol: unique symbol; + // @public export namespace mockApis { - export function alert(): MockAlertApi; + export function alert(): MockWithApiFactory; export namespace alert { const factory: () => any; const mock: ( @@ -148,7 +162,7 @@ export namespace mockApis { } export function featureFlags( options?: MockFeatureFlagsApiOptions, - ): MockFeatureFlagsApi; + ): MockWithApiFactory; export namespace featureFlags { const factory: (options?: MockFeatureFlagsApiOptions | undefined) => any; const mock: ( @@ -220,6 +234,11 @@ export { MockStorageApi }; export { MockStorageBucket }; +// @public +export type MockWithApiFactory = TApi & { + [mockApiFactorySymbol]: ApiFactory; +}; + export { registerMswTestHooks }; // @public @@ -253,9 +272,16 @@ export const TestApiProvider: ( props: TestApiProviderProps, ) => JSX_2.Element; +// @public +export type TestApiProviderEntry = + | readonly [ApiRef, any] + | MockWithApiFactory; + // @public export type TestApiProviderProps = { - apis: readonly [...TestApiProviderPropsApiPairs]; + apis: readonly [ + ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), + ]; children: ReactNode; }; @@ -271,9 +297,7 @@ export type TestApiProviderPropsApiPairs = { // @public export class TestApiRegistry implements ApiHolder { - static from( - ...apis: readonly [...TestApiProviderPropsApiPairs] - ): TestApiRegistry; + static from(...apis: readonly TestApiProviderEntry[]): TestApiRegistry; get(api: ApiRef): T | undefined; } diff --git a/packages/frontend-test-utils/src/apis/ApiMock.ts b/packages/frontend-test-utils/src/apis/ApiMock.ts new file mode 100644 index 0000000000..b5ee6f242b --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ApiMock.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2024 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 } from '@backstage/frontend-plugin-api'; +import { mockApiFactorySymbol } from './utils'; + +/** + * 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 = { + factory: ApiFactory; + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 09996209a6..4ac50392a1 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -24,11 +24,16 @@ export { MockPermissionApi, MockStorageApi, type MockStorageBucket, - type ApiMock, } from '@backstage/test-utils'; export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi'; export { mockApis } from './mockApis'; +export { + type MockApiFactorySymbol, + type ApiMock, + type MockWithApiFactory, + mockApiFactorySymbol, +} from './utils'; /** * @public @@ -38,4 +43,7 @@ export type { MockAlertApi } from './AlertApi'; /** * @public */ -export type { MockFeatureFlagsApi, MockFeatureFlagsApiOptions } from './FeatureFlagsApi'; +export type { + MockFeatureFlagsApi, + MockFeatureFlagsApiOptions, +} from './FeatureFlagsApi'; diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index 1cc965c0b8..92f1981d2f 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -19,15 +19,18 @@ import { createApiFactory, featureFlagsApiRef, } from '@backstage/frontend-plugin-api'; -import { - mockApis as testUtilsMockApis, - type ApiMock, -} from '@backstage/test-utils'; +import { mockApis as testUtilsMockApis } from '@backstage/test-utils'; import { MockAlertApi } from './AlertApi'; import { MockFeatureFlagsApi, MockFeatureFlagsApiOptions, } from './FeatureFlagsApi'; +import { + ApiMock, + mockWithApiFactory, + mockApiFactorySymbol, + type MockWithApiFactory, +} from './utils'; /** @internal */ function simpleMock( @@ -45,13 +48,15 @@ function simpleMock( } } } - return Object.assign(mock, { - factory: createApiFactory({ - api: ref, - deps: {}, - factory: () => mock, - }), - }) as ApiMock; + const factory = createApiFactory({ + api: ref, + deps: {}, + factory: () => mock, + }); + const apiMock = Object.assign(mock, { factory }) as ApiMock; + // Set the mock API symbol to the same factory + (apiMock as any)[mockApiFactorySymbol] = factory; + return apiMock; }; } @@ -119,8 +124,12 @@ export namespace mockApis { * expect(alertApi.getAlerts()).toHaveLength(1); * ``` */ - export function alert(): MockAlertApi { - return new MockAlertApi(); + export function alert(): MockWithApiFactory { + const instance = new MockAlertApi(); + return mockWithApiFactory( + alertApiRef, + instance, + ) as MockWithApiFactory; } /** * Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}. @@ -165,8 +174,12 @@ export namespace mockApis { */ export function featureFlags( options?: MockFeatureFlagsApiOptions, - ): MockFeatureFlagsApi { - return new MockFeatureFlagsApi(options); + ): MockWithApiFactory { + const instance = new MockFeatureFlagsApi(options); + return mockWithApiFactory( + featureFlagsApiRef, + instance, + ) as MockWithApiFactory; } /** * Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. diff --git a/packages/frontend-test-utils/src/apis/utils.ts b/packages/frontend-test-utils/src/apis/utils.ts new file mode 100644 index 0000000000..aca3cc67a1 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/utils.ts @@ -0,0 +1,97 @@ +/* + * 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. + * + * @public + */ +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; + +/** + * 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 = { + factory: ApiFactory; + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; + +/** + * 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: TApi, +): MockWithApiFactory { + const marked = implementation as MockWithApiFactory; + (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/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx index 97529033d0..3022f96f9c 100644 --- a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx +++ b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx @@ -18,6 +18,7 @@ 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 '../apis/utils'; /** * Helper type for representing an API reference paired with a partial implementation. @@ -41,13 +42,26 @@ export type TestApiProviderPropsApiPairs = { */ export type TestApiPairs = TestApiProviderPropsApiPairs; +/** + * Type for entries that can be passed to TestApiProvider/TestApiRegistry. + * Can be either a traditional [apiRef, implementation] tuple or a mock API instance + * marked with the MockApiSymbol. + * + * @public + */ +export type TestApiProviderEntry = + | readonly [ApiRef, any] + | MockWithApiFactory; + /** * Properties for the {@link TestApiProvider} component. * * @public */ export type TestApiProviderProps = { - apis: readonly [...TestApiProviderPropsApiPairs]; + apis: readonly [ + ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), + ]; children: ReactNode; }; @@ -75,20 +89,43 @@ export class TestApiRegistry implements ApiHolder { * import { identityApiRef } from '@backstage/frontend-plugin-api'; * import { mockApis } from '@backstage/frontend-test-utils'; * - * const apis = TestApiRegistry.from( + * // Traditional tuple syntax + * const apis1 = TestApiRegistry.from( * [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })], * ); + * + * // Direct mock API instance (no tuple needed) + * const apis2 = TestApiRegistry.from( + * mockApis.identity({ userEntityRef: 'user:default/guest' }), + * mockApis.alert(), + * ); * ``` * * @public - * @param apis - A list of pairs mapping an ApiRef to its respective implementation. + * @param apis - A list of pairs mapping an ApiRef to its respective implementation, + * or mock API instances marked with the mockApiSymbol. */ - static from( - ...apis: readonly [...TestApiProviderPropsApiPairs] - ) { - return new TestApiRegistry( - new Map(apis.map(([api, impl]) => [api.id, impl])), - ); + static from(...apis: readonly TestApiProviderEntry[]) { + const apiMap = new Map(); + + for (const entry of apis) { + const mockFactory = getMockApiFactory(entry); + if (mockFactory) { + // Handle mock API instances marked with the symbol + const impl = mockFactory.factory({}); + apiMap.set(mockFactory.api.id, impl); + } else if (Array.isArray(entry)) { + // Handle traditional [apiRef, impl] tuples + const [apiRef, impl] = entry; + apiMap.set(apiRef.id, impl); + } else { + throw new Error( + `Invalid API entry provided to TestApiRegistry.from(). Expected either [apiRef, impl] tuple or a mock API instance.`, + ); + } + } + + return new TestApiRegistry(apiMap); } private constructor(private readonly apis: Map) {} @@ -121,6 +158,7 @@ export class TestApiRegistry implements ApiHolder { * import { identityApiRef } from '\@backstage/frontend-plugin-api'; * import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils'; * + * // Traditional tuple syntax * render( * * * ); + * + * // Direct mock API instances (no tuples needed) + * render( + * + * + * + * ); * ``` * * @public diff --git a/packages/frontend-test-utils/src/utils/index.ts b/packages/frontend-test-utils/src/utils/index.ts index 2f6bfb5f9c..32724fabba 100644 --- a/packages/frontend-test-utils/src/utils/index.ts +++ b/packages/frontend-test-utils/src/utils/index.ts @@ -20,5 +20,6 @@ export { type TestApiProviderPropsApiPair, type TestApiProviderPropsApiPairs, type TestApiPairs, + type TestApiProviderEntry, } from './TestApiProvider'; export type { TestApiProviderProps } from './TestApiProvider'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx index 74c596b197..d362c9c7d9 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx @@ -15,15 +15,14 @@ */ import { ApiProvider } from '@backstage/core-app-api'; -import { alertApiRef, errorApiRef } from '@backstage/core-plugin-api'; +import { errorApiRef } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; -import { mockApis } from '@backstage/frontend-test-utils'; +import { renderWithEffects } from '@backstage/test-utils'; +import { mockApis, TestApiRegistry } 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({ @@ -38,9 +37,9 @@ const catalogApi = catalogApiMock.mock({ }), }); const apis = TestApiRegistry.from( + mockApis.alert(), + mockApis.translation(), [catalogApiRef, catalogApi], - [alertApiRef, mockApis.alert()], - [translationApiRef, mockApis.translation()], [errorApiRef, { post: jest.fn() }], ); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 6d5df6fb10..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 { TestApiProvider } from '@backstage/test-utils'; -import { mockApis } from '@backstage/frontend-test-utils'; +import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils'; const mockFeatureFlagsApi = mockApis.featureFlags.mock(); + const Wrapper = ({ children }: { children?: ReactNode }) => ( - - {children} - + {children} ); describe('EntitySwitch', () => { From 14611301e526e8f6334e15a655ae4daa63354f7a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Feb 2026 21:50:27 +0100 Subject: [PATCH 04/23] frontend-test-utils: copy over all mock APIs Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/package.json | 7 + packages/frontend-test-utils/report.api.md | 377 ++++++++++++++++-- .../src/apis/ConfigApi/MockConfigApi.test.ts | 42 ++ .../src/apis/ConfigApi/MockConfigApi.ts | 111 ++++++ .../src/apis/ConfigApi/index.ts | 17 + .../src/apis/ErrorApi/MockErrorApi.test.ts | 104 +++++ .../src/apis/ErrorApi/MockErrorApi.ts | 106 +++++ .../src/apis/ErrorApi/index.ts | 18 + .../src/apis/FetchApi/MockFetchApi.test.ts | 95 +++++ .../src/apis/FetchApi/MockFetchApi.ts | 169 ++++++++ .../src/apis/FetchApi/index.ts | 18 + .../PermissionApi/MockPermissionApi.test.ts | 47 +++ .../apis/PermissionApi/MockPermissionApi.ts | 45 +++ .../src/apis/PermissionApi/index.ts | 17 + .../apis/StorageApi/MockStorageApi.test.ts | 282 +++++++++++++ .../src/apis/StorageApi/MockStorageApi.ts | 148 +++++++ .../src/apis/StorageApi/index.ts | 18 + .../MockTranslationApi.test.tsx | 212 ++++++++++ .../apis/TranslationApi/MockTranslationApi.ts | 110 +++++ .../src/apis/TranslationApi/index.ts | 17 + .../frontend-test-utils/src/apis/index.ts | 52 ++- .../frontend-test-utils/src/apis/mockApis.ts | 326 ++++++++++++++- .../frontend-test-utils/src/apis/utils.ts | 10 +- yarn.lock | 7 + 24 files changed, 2294 insertions(+), 61 deletions(-) create mode 100644 packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts create mode 100644 packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts create mode 100644 packages/frontend-test-utils/src/apis/ConfigApi/index.ts create mode 100644 packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.test.ts create mode 100644 packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.ts create mode 100644 packages/frontend-test-utils/src/apis/ErrorApi/index.ts create mode 100644 packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts create mode 100644 packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts create mode 100644 packages/frontend-test-utils/src/apis/FetchApi/index.ts create mode 100644 packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.test.ts create mode 100644 packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.ts create mode 100644 packages/frontend-test-utils/src/apis/PermissionApi/index.ts create mode 100644 packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts create mode 100644 packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts create mode 100644 packages/frontend-test-utils/src/apis/StorageApi/index.ts create mode 100644 packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.test.tsx create mode 100644 packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts create mode 100644 packages/frontend-test-utils/src/apis/TranslationApi/index.ts diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index a0191817e0..e4e03602d2 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -32,13 +32,19 @@ }, "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:^", + "cross-fetch": "^4.0.0", + "i18next": "^22.4.15", "zen-observable": "^0.10.0", "zod": "^3.25.76" }, @@ -47,6 +53,7 @@ "@testing-library/jest-dom": "^6.0.0", "@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" diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 7c5f0fcd87..3f5bf3930d 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -12,7 +12,19 @@ import { ApiHolder } 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 crossFetch from 'cross-fetch'; +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'; @@ -20,24 +32,28 @@ 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 { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react/jsx-runtime'; -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'; -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 { 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 { StorageApi } from '@backstage/core-plugin-api'; +import { StorageApi as StorageApi_2 } from '@backstage/frontend-plugin-api'; +import { StorageValueSnapshot } from '@backstage/core-plugin-api'; import { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries'; +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'; // @public @@ -62,7 +78,11 @@ export function createExtensionTester< }, ): ExtensionTester>; -export { ErrorWithContext }; +// @public +export type ErrorWithContext = { + error: ErrorApiError; + context?: ErrorApiErrorContext; +}; // @public (undocumented) export class ExtensionQuery { @@ -160,6 +180,97 @@ export namespace mockApis { alert$: jest.Mock; }>; } + export function analytics(): MockAnalyticsApi & + MockWithApiFactory; + export namespace analytics { + const // (undocumented) + mock: ( + partialImpl?: + | Partial<{ + captureEvent: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + captureEvent: jest.Mock; + }>; + } + export function config(options?: { + data?: JsonObject; + }): MockConfigApi & MockWithApiFactory; + export namespace config { + const // (undocumented) + mock: ( + partialImpl?: + | Partial<{ + has: jest.Mock; + keys: jest.Mock; + get: jest.Mock; + getOptional: jest.Mock; + getConfig: jest.Mock; + getOptionalConfig: jest.Mock; + getConfigArray: jest.Mock; + getOptionalConfigArray: jest.Mock; + getNumber: jest.Mock; + getOptionalNumber: jest.Mock; + getBoolean: jest.Mock; + getOptionalBoolean: jest.Mock; + getString: jest.Mock; + getOptionalString: jest.Mock; + getStringArray: jest.Mock; + getOptionalStringArray: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + has: jest.Mock; + keys: jest.Mock; + get: jest.Mock; + getOptional: jest.Mock; + getConfig: jest.Mock; + getOptionalConfig: jest.Mock; + getConfigArray: jest.Mock; + getOptionalConfigArray: jest.Mock; + getNumber: jest.Mock; + getOptionalNumber: jest.Mock; + getBoolean: jest.Mock; + getOptionalBoolean: jest.Mock; + getString: jest.Mock; + getOptionalString: jest.Mock; + getStringArray: jest.Mock; + getOptionalStringArray: jest.Mock; + }>; + } + export function discovery(options?: { + baseUrl?: string; + }): DiscoveryApi & MockWithApiFactory; + export namespace discovery { + const // (undocumented) + mock: ( + partialImpl?: + | Partial<{ + getBaseUrl: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + getBaseUrl: jest.Mock; + }>; + } + export function error( + options?: MockErrorApiOptions, + ): MockErrorApi & MockWithApiFactory; + export namespace error { + const // (undocumented) + mock: ( + partialImpl?: + | Partial<{ + post: jest.Mock; + error$: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + post: jest.Mock; + error$: jest.Mock; + }>; + } export function featureFlags( options?: MockFeatureFlagsApiOptions, ): MockWithApiFactory; @@ -181,27 +292,148 @@ export namespace mockApis { 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 function fetch( + options?: MockFetchApiOptions, + ): MockFetchApi & MockWithApiFactory; + export namespace fetch { + const // (undocumented) + mock: ( + partialImpl?: + | Partial<{ + fetch: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + fetch: jest.Mock; + }>; + } + export function identity(options?: { + userEntityRef?: string; + ownershipEntityRefs?: string[]; + token?: string; + email?: string; + displayName?: string; + picture?: string; + }): IdentityApi & MockWithApiFactory; + export namespace identity { + const // (undocumented) + mock: ( + partialImpl?: + | Partial<{ + getBackstageIdentity: jest.Mock; + getCredentials: jest.Mock; + getProfileInfo: jest.Mock; + signOut: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + getBackstageIdentity: jest.Mock; + getCredentials: jest.Mock; + getProfileInfo: jest.Mock; + signOut: jest.Mock; + }>; + } + 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<{ + authorize: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + authorize: jest.Mock; + }>; + } + export function storage(options?: { + data?: JsonObject; + }): MockStorageApi & MockWithApiFactory; + export namespace storage { + const // (undocumented) + mock: ( + partialImpl?: + | Partial<{ + forBucket: jest.Mock; + snapshot: jest.Mock; + set: jest.Mock; + remove: jest.Mock; + observe$: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + forBucket: jest.Mock; + snapshot: jest.Mock; + set: jest.Mock; + remove: jest.Mock; + observe$: jest.Mock; + }>; + } + export function translation(): MockTranslationApi & + MockWithApiFactory; + export namespace translation { + const mock: ( + partialImpl?: + | Partial<{ + getTranslation: jest.Mock; + translation$: jest.Mock; + }> + | undefined, + ) => ApiMock<{ + getTranslation: jest.Mock; + translation$: jest.Mock; + }>; + } } -export { MockConfigApi }; +// @public +export class MockConfigApi implements ConfigApi { + constructor(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 { MockErrorApi }; +// @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 { MockErrorApiOptions }; +// @public +export type MockErrorApiOptions = { + collect?: boolean; +}; // @public export class MockFeatureFlagsApi implements FeatureFlagsApi { @@ -224,15 +456,85 @@ export interface MockFeatureFlagsApiOptions { initialStates?: Record; } -export { MockFetchApi }; +// @public +export class MockFetchApi implements FetchApi { + constructor(options?: MockFetchApiOptions); + get fetch(): typeof crossFetch; +} -export { MockFetchApiOptions }; +// @public +export interface MockFetchApiOptions { + baseImplementation?: undefined | 'none' | typeof crossFetch; + injectIdentityAuth?: + | undefined + | { + token: string; + } + | { + identityApi: Pick; + }; + resolvePluginProtocol?: + | undefined + | { + discoveryApi: Pick; + }; +} -export { MockPermissionApi }; +// @public +export class MockPermissionApi implements PermissionApi { + constructor( + requestHandler?: ( + request: EvaluatePermissionRequest, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY, + ); + // (undocumented) + authorize( + request: EvaluatePermissionRequest, + ): Promise; +} -export { MockStorageApi }; +// @public +export class MockStorageApi implements StorageApi { + // (undocumented) + static create(data?: MockStorageBucket): 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; +} -export { MockStorageBucket }; +// @public +export type MockStorageBucket = { + [key: string]: any; +}; + +// @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 & { @@ -286,8 +588,13 @@ export type TestApiProviderProps = { }; // @public -export type TestApiProviderPropsApiPair = TApi extends infer TImpl - ? readonly [ApiRef, Partial] +export type TestApiProviderPropsApiPair = TApi extends readonly [ + infer _Ref, + infer _Impl, +] + ? TApi + : TApi extends infer TImpl + ? readonly [ApiRef, Partial] | MockWithApiFactory : never; // @public 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..972733d44d --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts @@ -0,0 +1,42 @@ +/* + * 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({ + 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..372c295c02 --- /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({ + * 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: 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/FetchApi/MockFetchApi.test.ts b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts new file mode 100644 index 0000000000..87598de2a3 --- /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 { rest } from 'msw'; +import { setupServer } from 'msw'; +import { registerMswTestHooks } from '@backstage/test-utils'; +import { MockFetchApi } from './MockFetchApi'; + +describe('MockFetchApi', () => { + const worker = setupServer(); + registerMswTestHooks(worker); + + it('works with default constructor', async () => { + worker.use( + rest.get('http://example.com/data.json', (_, res, ctx) => + res(ctx.status(200), ctx.json({ a: 'foo' })), + ), + ); + 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..661d202379 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts @@ -0,0 +1,169 @@ +/* + * 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'; +import crossFetch, { Response } from 'cross-fetch'; + +/** + * 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 crossFetch; + + /** + * 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 crossFetch { + 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 crossFetch { + 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/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..cdae96d5e4 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts @@ -0,0 +1,282 @@ +/* + * 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, + newValue: 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, + newValue: 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..2585264259 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts @@ -0,0 +1,148 @@ +/* + * 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 { JsonValue, Observable } from '@backstage/types'; +import ObservableImpl from 'zen-observable'; + +/** + * Type for map holding data in {@link MockStorageApi} + * + * @public + */ +export type MockStorageBucket = { [key: string]: any }; + +/** + * 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: MockStorageBucket; + private readonly bucketStorageApis: Map; + + private constructor( + namespace: string, + bucketStorageApis: Map, + data?: MockStorageBucket, + ) { + this.namespace = namespace; + this.bucketStorageApis = bucketStorageApis; + this.data = { ...data }; + } + + static create(data?: MockStorageBucket) { + // 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, + }; + } + 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..a42fbb3b50 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/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 { MockStorageApi } from './MockStorageApi'; +export type { MockStorageBucket } from './MockStorageApi'; 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..bce96dd50b --- /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); + } + + #i18n: I18n; + #interpolator: JsxInterpolator; + #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/apis/TranslationApi/index.ts b/packages/frontend-test-utils/src/apis/TranslationApi/index.ts new file mode 100644 index 0000000000..2c10347545 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/TranslationApi/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { MockTranslationApi } from './MockTranslationApi'; diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 4ac50392a1..71a7dff3b5 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -14,19 +14,6 @@ * limitations under the License. */ -export { - MockConfigApi, - type ErrorWithContext, - MockErrorApi, - type MockErrorApiOptions, - MockFetchApi, - type MockFetchApiOptions, - MockPermissionApi, - MockStorageApi, - type MockStorageBucket, -} from '@backstage/test-utils'; - -export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi'; export { mockApis } from './mockApis'; export { type MockApiFactorySymbol, @@ -40,6 +27,30 @@ export { */ 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 */ @@ -47,3 +58,18 @@ export type { MockFeatureFlagsApi, MockFeatureFlagsApiOptions, } from './FeatureFlagsApi'; + +/** + * @public + */ +export type { MockPermissionApi } from './PermissionApi'; + +/** + * @public + */ +export type { MockStorageApi, MockStorageBucket } from './StorageApi'; + +/** + * @public + */ +export type { MockTranslationApi } from './TranslationApi'; diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index 92f1981d2f..e6474dbe96 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -16,15 +16,46 @@ import { alertApiRef, + analyticsApiRef, + configApiRef, createApiFactory, + 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 { mockApis as testUtilsMockApis } from '@backstage/test-utils'; +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 { ApiMock, mockWithApiFactory, @@ -211,12 +242,289 @@ export namespace mockApis { })); } - // 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; + /** + * 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 = simpleMock(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 = simpleMock(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(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 = simpleMock(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 = simpleMock(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; + }): IdentityApi & 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 = simpleMock(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 = simpleMock(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 = simpleMock(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 = simpleMock(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 = simpleMock(fetchApiRef, () => ({ + fetch: jest.fn(), + })); + } } diff --git a/packages/frontend-test-utils/src/apis/utils.ts b/packages/frontend-test-utils/src/apis/utils.ts index aca3cc67a1..197ab514af 100644 --- a/packages/frontend-test-utils/src/apis/utils.ts +++ b/packages/frontend-test-utils/src/apis/utils.ts @@ -63,11 +63,13 @@ export type MockWithApiFactory = TApi & { * * @internal */ -export function mockWithApiFactory( +export function mockWithApiFactory( apiRef: ApiRef, - implementation: TApi, -): MockWithApiFactory { - const marked = implementation as MockWithApiFactory; + implementation: TImpl, +): TImpl & { [mockApiFactorySymbol]: ApiFactory } { + const marked = implementation as TImpl & { + [mockApiFactorySymbol]: ApiFactory; + }; (marked as any)[mockApiFactorySymbol] = { api: apiRef, deps: {}, diff --git a/yarn.lock b/yarn.lock index f00bf67f21..a5ff49f678 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3952,16 +3952,23 @@ __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/react": "npm:^18.0.0" "@types/zen-observable": "npm:^0.8.0" + cross-fetch: "npm:^4.0.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" From 0d7e331265a5ff9d191b145c3306cd6318b711dd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Feb 2026 22:37:38 +0100 Subject: [PATCH 05/23] frontend-test-utils: shorthand mocks everywhere Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/report.api.md | 22 ++++++------ .../src/apis/FetchApi/MockFetchApi.test.ts | 10 +++--- .../src/app/createExtensionTester.test.tsx | 4 +-- .../src/app/renderInTestApp.test.tsx | 15 ++++---- .../src/app/renderInTestApp.tsx | 22 ++++++++---- .../src/app/renderTestApp.tsx | 36 +++++++++++-------- .../src/utils/TestApiProvider.tsx | 2 +- 7 files changed, 61 insertions(+), 50 deletions(-) diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 3f5bf3930d..6b0b4d37b5 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -49,7 +49,6 @@ import { RouteRef } from '@backstage/frontend-plugin-api'; 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 { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries'; 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'; @@ -551,8 +550,8 @@ export function renderInTestApp( // @public export function renderTestApp( - options: RenderTestAppOptions, -): RenderResult; + options?: RenderTestAppOptions, +): RenderResult; // @public export type RenderTestAppOptions = { @@ -563,7 +562,9 @@ export type RenderTestAppOptions = { mountedRoutes?: { [path: string]: RouteRef; }; - apis?: readonly [...TestApiPairs]; + apis?: readonly [ + ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), + ]; }; // @public @@ -588,13 +589,8 @@ export type TestApiProviderProps = { }; // @public -export type TestApiProviderPropsApiPair = TApi extends readonly [ - infer _Ref, - infer _Impl, -] - ? TApi - : TApi extends infer TImpl - ? readonly [ApiRef, Partial] | MockWithApiFactory +export type TestApiProviderPropsApiPair = TApi extends infer TImpl + ? readonly [ApiRef, Partial] : never; // @public @@ -616,7 +612,9 @@ export type TestAppOptions = { config?: JsonObject; features?: FrontendFeature[]; initialRouteEntries?: string[]; - apis?: readonly [...TestApiPairs]; + apis?: readonly [ + ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), + ]; }; export { withLogCollector }; diff --git a/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts index 87598de2a3..ae4486d38f 100644 --- a/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts +++ b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { rest } from 'msw'; -import { setupServer } from 'msw'; -import { registerMswTestHooks } from '@backstage/test-utils'; +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { registerMswTestHooks } from '../../..'; import { MockFetchApi } from './MockFetchApi'; describe('MockFetchApi', () => { @@ -25,8 +25,8 @@ describe('MockFetchApi', () => { it('works with default constructor', async () => { worker.use( - rest.get('http://example.com/data.json', (_, res, ctx) => - res(ctx.status(200), ctx.json({ a: 'foo' })), + http.get('http://example.com/data.json', () => + HttpResponse.json({ a: 'foo' }, { status: 200 }), ), ); const m = new MockFetchApi(); 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/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..a84b7063d6 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -32,10 +32,12 @@ 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 { type TestApiProviderPropsApiPairs } from '../utils'; +import { getMockApiFactory, type MockWithApiFactory } from '../apis/utils'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp'; @@ -88,15 +90,16 @@ 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' })], * }) * ``` */ - apis?: readonly [...TestApiPairs]; + apis?: readonly [ + ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), + ]; }; const NavItem = (props: { @@ -241,9 +244,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..b7b2c2dd6e 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -25,14 +25,16 @@ 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 { type TestApiProviderPropsApiPairs } from '../utils'; +import { getMockApiFactory, type MockWithApiFactory } from '../apis/utils'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp'; @@ -89,16 +91,17 @@ 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: [...], * }) * ``` */ - apis?: readonly [...TestApiPairs]; + apis?: readonly [ + ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), + ]; }; const appPluginOverride = appPlugin.withOverrides({ @@ -116,11 +119,11 @@ const appPluginOverride = appPlugin.withOverrides({ * @public */ export function renderTestApp( - options: RenderTestAppOptions, -) { - const extensions = [...(options.extensions ?? [])]; + 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 +153,7 @@ export function renderTestApp( params: { component: ({ children }) => ( ( appPluginOverride, ]; - if (options.features) { + if (options?.features) { features.push(...options.features); } @@ -183,9 +186,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/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx index 3022f96f9c..4bd02a76c4 100644 --- a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx +++ b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx @@ -45,7 +45,7 @@ export type TestApiPairs = TestApiProviderPropsApiPairs; /** * Type for entries that can be passed to TestApiProvider/TestApiRegistry. * Can be either a traditional [apiRef, implementation] tuple or a mock API instance - * marked with the MockApiSymbol. + * marked with the mockApiFactorySymbol. * * @public */ From 6362ca4b17e6ffca47c8215b3326d6a04e167474 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Feb 2026 23:36:53 +0100 Subject: [PATCH 06/23] catalog-react: factory mock shortcut for catalogApiMock Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/report.api.md | 11 +++++-- .../frontend-test-utils/src/apis/index.ts | 2 +- .../frontend-test-utils/src/apis/utils.ts | 33 ++++++++++++++++++- plugins/catalog-graph/src/alpha.test.tsx | 7 ++-- plugins/catalog-react/report-testUtils.api.md | 5 ++- .../src/testUtils/catalogApiMock.ts | 27 +++++++++------ .../testUtils/createTestEntityPage.test.tsx | 3 +- plugins/org/src/alpha.test.tsx | 10 ++---- 8 files changed, 68 insertions(+), 30 deletions(-) diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 6b0b4d37b5..d3951a03e8 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -65,6 +65,14 @@ export type ApiMock = { : TApi[Key]; }; +// @public +export function attachMockApiFactory( + apiRef: ApiRef, + implementation: TImpl, +): TImpl & { + [mockApiFactorySymbol]: ApiFactory; +}; + // @public (undocumented) export function createExtensionTester< T extends ExtensionDefinitionParameters, @@ -159,9 +167,6 @@ export class MockAnalyticsApi implements AnalyticsApi { // @public export type MockApiFactorySymbol = typeof mockApiFactorySymbol; -// @public -export const mockApiFactorySymbol: unique symbol; - // @public export namespace mockApis { export function alert(): MockWithApiFactory; diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 71a7dff3b5..c0275bef3a 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -19,7 +19,7 @@ export { type MockApiFactorySymbol, type ApiMock, type MockWithApiFactory, - mockApiFactorySymbol, + attachMockApiFactory, } from './utils'; /** diff --git a/packages/frontend-test-utils/src/apis/utils.ts b/packages/frontend-test-utils/src/apis/utils.ts index 197ab514af..b6f6af6753 100644 --- a/packages/frontend-test-utils/src/apis/utils.ts +++ b/packages/frontend-test-utils/src/apis/utils.ts @@ -20,7 +20,7 @@ import { ApiFactory } from '@backstage/frontend-plugin-api'; /** * Symbol used to mark mock API instances with their corresponding API factory. * - * @public + * @ignore */ export const mockApiFactorySymbol = Symbol.for('@backstage/mock-api'); @@ -78,6 +78,37 @@ export function mockWithApiFactory( 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. 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-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/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts index a37d2669ad..dd225adc54 100644 --- a/plugins/catalog-react/src/testUtils/catalogApiMock.ts +++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts @@ -23,7 +23,11 @@ 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'; +import { + ApiMock, + attachMockApiFactory, + type MockWithApiFactory, +} from '@backstage/frontend-test-utils'; /** @internal */ function simpleMock( @@ -41,13 +45,13 @@ function simpleMock( } } } - return Object.assign(mock, { - factory: createApiFactory({ - api: ref, - deps: {}, - factory: () => mock, - }), - }) as ApiMock; + const factory = createApiFactory({ + api: ref, + deps: {}, + factory: () => mock, + }); + const marked = attachMockApiFactory(ref, mock); + return Object.assign(marked, { factory }) as ApiMock; }; } @@ -58,8 +62,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); } /** 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/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( From a18c64a285bb312ad853213f53306886fa5e0d37 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 5 Feb 2026 23:53:12 +0100 Subject: [PATCH 07/23] frontend-test-utils: mock type exports comment Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/src/apis/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index c0275bef3a..068c0a05ec 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -22,6 +22,11 @@ export { attachMockApiFactory, } from './utils'; +/** + * 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 */ From b9110a5724e89cf32b4cb70fe5feb5c4bd71252a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 6 Feb 2026 22:48:22 +0100 Subject: [PATCH 08/23] frontend-test-utils: api cleanup, remove TestApiRegistry Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/report.api.md | 21 ---- .../src/{utils => apis}/TestApiProvider.tsx | 119 +++++------------- .../frontend-test-utils/src/apis/index.ts | 8 ++ .../src/app/createExtensionTester.tsx | 7 +- .../src/app/renderInTestApp.tsx | 2 +- .../src/app/renderTestApp.tsx | 2 +- packages/frontend-test-utils/src/index.ts | 3 +- .../frontend-test-utils/src/utils/index.ts | 25 ---- .../SelectedKindsFilter.test.tsx | 33 +++-- 9 files changed, 63 insertions(+), 157 deletions(-) rename packages/frontend-test-utils/src/{utils => apis}/TestApiProvider.tsx (51%) delete mode 100644 packages/frontend-test-utils/src/utils/index.ts diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index d3951a03e8..aa24db3a96 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -580,11 +580,6 @@ export const TestApiProvider: ( props: TestApiProviderProps, ) => JSX_2.Element; -// @public -export type TestApiProviderEntry = - | readonly [ApiRef, any] - | MockWithApiFactory; - // @public export type TestApiProviderProps = { apis: readonly [ @@ -593,22 +588,6 @@ export type TestApiProviderProps = { 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 TestApiProviderEntry[]): TestApiRegistry; - get(api: ApiRef): T | undefined; -} - // @public export type TestAppOptions = { mountedRoutes?: { diff --git a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx similarity index 51% rename from packages/frontend-test-utils/src/utils/TestApiProvider.tsx rename to packages/frontend-test-utils/src/apis/TestApiProvider.tsx index 4bd02a76c4..90dd53f61a 100644 --- a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx +++ b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx @@ -18,11 +18,11 @@ 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 '../apis/utils'; +import { getMockApiFactory, type MockWithApiFactory } from './utils'; /** * Helper type for representing an API reference paired with a partial implementation. - * @public + * @ignore */ export type TestApiProviderPropsApiPair = TApi extends infer TImpl ? readonly [ApiRef, Partial] @@ -30,7 +30,7 @@ export type TestApiProviderPropsApiPair = TApi extends infer TImpl /** * Helper type for representing an array of API reference pairs. - * @public + * @ignore */ export type TestApiProviderPropsApiPairs = { [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; @@ -43,16 +43,37 @@ export type TestApiProviderPropsApiPairs = { export type TestApiPairs = TestApiProviderPropsApiPairs; /** - * Type for entries that can be passed to TestApiProvider/TestApiRegistry. + * Type for entries that can be passed to TestApiProvider. * Can be either a traditional [apiRef, implementation] tuple or a mock API instance * marked with the mockApiFactorySymbol. * - * @public + * @internal */ export type TestApiProviderEntry = | readonly [ApiRef, any] | MockWithApiFactory; +/** @internal */ +export function resolveTestApiEntries( + apis: readonly (TestApiProviderEntry | readonly [ApiRef, any])[], +): 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. * @@ -65,81 +86,6 @@ export type TestApiProviderProps = { 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'; - * - * // Traditional tuple syntax - * const apis1 = TestApiRegistry.from( - * [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })], - * ); - * - * // Direct mock API instance (no tuple needed) - * const apis2 = TestApiRegistry.from( - * mockApis.identity({ userEntityRef: 'user:default/guest' }), - * mockApis.alert(), - * ); - * ``` - * - * @public - * @param apis - A list of pairs mapping an ApiRef to its respective implementation, - * or mock API instances marked with the mockApiSymbol. - */ - static from(...apis: readonly TestApiProviderEntry[]) { - const apiMap = new Map(); - - for (const entry of apis) { - const mockFactory = getMockApiFactory(entry); - if (mockFactory) { - // Handle mock API instances marked with the symbol - const impl = mockFactory.factory({}); - apiMap.set(mockFactory.api.id, impl); - } else if (Array.isArray(entry)) { - // Handle traditional [apiRef, impl] tuples - const [apiRef, impl] = entry; - apiMap.set(apiRef.id, impl); - } else { - throw new Error( - `Invalid API entry provided to TestApiRegistry.from(). Expected either [apiRef, impl] tuple or a mock API instance.`, - ); - } - } - - return new TestApiRegistry(apiMap); - } - - 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. @@ -155,19 +101,22 @@ export class TestApiRegistry implements ApiHolder { * @example * ```tsx * import { render } from '\@testing-library/react'; - * import { identityApiRef } from '\@backstage/frontend-plugin-api'; + * import { myCustomApiRef } from '../apis'; * import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils'; * - * // Traditional tuple syntax + * // Mock custom APIs with tuple syntax + * const myCustomApiMock = { myMethod: jest.fn() }; * render( * * * * ); * - * // Direct mock API instances (no tuples needed) + * // Use with built-in mock APIs (no tuples needed) * render( * ( ) => { return ( ); diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 068c0a05ec..1a3ce150c9 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -21,6 +21,14 @@ export { type MockWithApiFactory, attachMockApiFactory, } from './utils'; +export { + TestApiProvider, + type TestApiProviderPropsApiPair, + type TestApiProviderPropsApiPairs, + type TestApiPairs, + type TestApiProviderEntry, +} from './TestApiProvider'; +export type { TestApiProviderProps } from './TestApiProvider'; /** * Mock API classes are exported as types only to prevent direct instantiation. diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 91a7bf9022..ddceab52bd 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -38,7 +38,8 @@ 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 { type TestApiPairs } from '../apis'; +import { resolveTestApiEntries } from '../apis/TestApiProvider'; /** * Represents a snapshot of an extension in the app tree. @@ -281,9 +282,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.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index a84b7063d6..79bab70653 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -36,7 +36,7 @@ import { } from '@backstage/frontend-plugin-api'; import { RouterBlueprint } from '@backstage/plugin-app-react'; import appPlugin from '@backstage/plugin-app'; -import { type TestApiProviderPropsApiPairs } from '../utils'; +import { type TestApiProviderPropsApiPairs } from '../apis'; import { getMockApiFactory, type MockWithApiFactory } from '../apis/utils'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp'; diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx index b7b2c2dd6e..78efb05a41 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -33,7 +33,7 @@ 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 TestApiProviderPropsApiPairs } from '../utils'; +import { type TestApiProviderPropsApiPairs } from '../apis'; import { getMockApiFactory, type MockWithApiFactory } from '../apis/utils'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp'; diff --git a/packages/frontend-test-utils/src/index.ts b/packages/frontend-test-utils/src/index.ts index b709e7ac03..03f08ab929 100644 --- a/packages/frontend-test-utils/src/index.ts +++ b/packages/frontend-test-utils/src/index.ts @@ -22,10 +22,9 @@ export * from './apis'; export * from './app'; -export * from './utils'; // Explicit export to satisfy API Extractor -export type { TestApiPairs } from './utils'; +export type { TestApiPairs } from './apis'; export { withLogCollector } from '@backstage/test-utils'; diff --git a/packages/frontend-test-utils/src/utils/index.ts b/packages/frontend-test-utils/src/utils/index.ts deleted file mode 100644 index 32724fabba..0000000000 --- a/packages/frontend-test-utils/src/utils/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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. - */ - -export { - TestApiProvider, - TestApiRegistry, - type TestApiProviderPropsApiPair, - type TestApiProviderPropsApiPairs, - type TestApiPairs, - type TestApiProviderEntry, -} from './TestApiProvider'; -export type { TestApiProviderProps } from './TestApiProvider'; diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx index d362c9c7d9..b8822de1c1 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx @@ -14,11 +14,8 @@ * limitations under the License. */ -import { ApiProvider } from '@backstage/core-app-api'; -import { errorApiRef } from '@backstage/core-plugin-api'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { renderWithEffects } from '@backstage/test-utils'; -import { mockApis, TestApiRegistry } from '@backstage/frontend-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'; @@ -36,28 +33,28 @@ const catalogApi = catalogApiMock.mock({ }, }), }); -const apis = TestApiRegistry.from( +const apis = [ mockApis.alert(), mockApis.translation(), - [catalogApiRef, catalogApi], - [errorApiRef, { post: jest.fn() }], -); + 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(); @@ -67,9 +64,9 @@ describe('', () => { it('should select value', async () => { const onChange = jest.fn(); await renderWithEffects( - + - , + , ); await userEvent.click(screen.getByLabelText('Open')); @@ -85,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')); @@ -108,9 +105,9 @@ describe('', () => { it('should return all values when cleared', async () => { const onChange = jest.fn(); await renderWithEffects( - + - , + , ); await userEvent.click(screen.getByRole('combobox')); From 09a6aadcc985f7794df0b58286f522b2ed09381b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 6 Feb 2026 22:59:53 +0100 Subject: [PATCH 09/23] changesets: added changesets for frontend-test-utils api mock changes Signed-off-by: Patrik Oldsberg --- .changeset/beige-crabs-share.md | 5 +++++ ...atalog-react-catalog-api-mock-shorthand.md | 5 +++++ ...tend-test-utils-mock-apis-and-shorthand.md | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 .changeset/beige-crabs-share.md create mode 100644 .changeset/catalog-react-catalog-api-mock-shorthand.md create mode 100644 .changeset/frontend-test-utils-mock-apis-and-shorthand.md diff --git a/.changeset/beige-crabs-share.md b/.changeset/beige-crabs-share.md new file mode 100644 index 0000000000..00d8c2c743 --- /dev/null +++ b/.changeset/beige-crabs-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': minor +--- + +**BREAKING**: Removed the `TestApiRegistry` class, use `TestApiProvider` directory instead, storing resused APIs in an a variable instead, 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-test-utils-mock-apis-and-shorthand.md b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md new file mode 100644 index 0000000000..945eaf75c1 --- /dev/null +++ b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md @@ -0,0 +1,19 @@ +--- +'@backstage/frontend-test-utils': patch +--- + +Added a new `mockApis` namespace with mock implementations of many core APIs. Mock API instances can be passed directly to `TestApiProvider`, `renderInTestApp`, and `renderTestApp` without needing `[apiRef, impl]` tuples. + +```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()], +}); +``` From 0a1faaa07696abfefe2b046127b986e1d2a93de1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 7 Feb 2026 00:06:58 +0100 Subject: [PATCH 10/23] docs/frontend-system: add utility API testing docs Signed-off-by: Patrik Oldsberg --- .../building-plugins/02-testing.md | 2 +- docs/frontend-system/utility-apis/01-index.md | 8 + .../utility-apis/05-testing.md | 172 ++++++++++++++++++ microsite/sidebars.ts | 1 + 4 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 docs/frontend-system/utility-apis/05-testing.md 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..f1a9110590 --- /dev/null +++ b/docs/frontend-system/utility-apis/05-testing.md @@ -0,0 +1,172 @@ +--- +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 three 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'; + +const catalogApi = mockApis.permission.mock({ + authorize: async () => ({ result: AuthorizeResult.ALLOW }), +}); + +// ... exercise the component ... + +expect(catalogApi.authorize).toHaveBeenCalledTimes(1); +``` + +### API factories + +Call `.factory()` to get an `ApiFactory` suitable for more advanced wiring scenarios. + +## 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 with `attachMockApiFactory` + +If you maintain a plugin that exposes a utility API, you can use `attachMockApiFactory` to create mock instances that can be passed directly to test utilities: + +```ts +import { attachMockApiFactory } from '@backstage/frontend-test-utils'; +import { myApiRef, type MyApi } from '@internal/plugin-example-react'; + +export function myApiMock(options?: { greeting?: string }): MyApi { + const instance: MyApi = { + greet: async () => options?.greeting ?? 'Hello!', + }; + return attachMockApiFactory(myApiRef, instance); +} +``` + +Consumers can then use it like the built-in mocks: + +```tsx +await renderInTestApp(, { + apis: [myApiMock({ greeting: 'Hi there!' })], +}); +``` + +## 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 `cross-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', ], ), ], From f4bb4d1983a85924343b5e7db460aa448300902d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 7 Feb 2026 00:34:36 +0100 Subject: [PATCH 11/23] frontend-test-utils: remove redundant .factory util Signed-off-by: Patrik Oldsberg --- ...tend-test-utils-mock-apis-and-shorthand.md | 4 +- .../utility-apis/05-testing.md | 6 +-- packages/frontend-test-utils/report.api.md | 2 - .../frontend-test-utils/src/apis/mockApis.ts | 37 +------------------ 4 files changed, 4 insertions(+), 45 deletions(-) diff --git a/.changeset/frontend-test-utils-mock-apis-and-shorthand.md b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md index 945eaf75c1..546d2e102c 100644 --- a/.changeset/frontend-test-utils-mock-apis-and-shorthand.md +++ b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md @@ -1,8 +1,8 @@ --- -'@backstage/frontend-test-utils': patch +'@backstage/frontend-test-utils': minor --- -Added a new `mockApis` namespace with mock implementations of many core APIs. Mock API instances can be passed directly to `TestApiProvider`, `renderInTestApp`, and `renderTestApp` without needing `[apiRef, impl]` tuples. +**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 diff --git a/docs/frontend-system/utility-apis/05-testing.md b/docs/frontend-system/utility-apis/05-testing.md index f1a9110590..fc92eb2abb 100644 --- a/docs/frontend-system/utility-apis/05-testing.md +++ b/docs/frontend-system/utility-apis/05-testing.md @@ -9,7 +9,7 @@ When testing frontend components and extensions, you often need to provide mock ## The `mockApis` namespace -The `mockApis` namespace is the main entry point for creating mock utility API instances in tests. It provides three usage patterns for each API: +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 @@ -42,10 +42,6 @@ const catalogApi = mockApis.permission.mock({ expect(catalogApi.authorize).toHaveBeenCalledTimes(1); ``` -### API factories - -Call `.factory()` to get an `ApiFactory` suitable for more advanced wiring scenarios. - ## Providing mock APIs in tests ### With `renderInTestApp` diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index aa24db3a96..bef42bc9e0 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -171,7 +171,6 @@ export type MockApiFactorySymbol = typeof mockApiFactorySymbol; export namespace mockApis { export function alert(): MockWithApiFactory; export namespace alert { - const factory: () => any; const mock: ( partialImpl?: | Partial<{ @@ -279,7 +278,6 @@ export namespace mockApis { options?: MockFeatureFlagsApiOptions, ): MockWithApiFactory; export namespace featureFlags { - const factory: (options?: MockFeatureFlagsApiOptions | undefined) => any; const mock: ( partialImpl?: | Partial<{ diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index e6474dbe96..01def834e2 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -91,19 +91,6 @@ function simpleMock( }; } -/** @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. * @@ -111,7 +98,7 @@ function simpleFactory( * @remarks * * There are some variations among the APIs depending on what needs tests - * might have, but overall there are three main usage patterns: + * 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. @@ -133,14 +120,6 @@ function simpleFactory( * 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 { /** @@ -169,13 +148,6 @@ export namespace mockApis { * @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 @@ -219,13 +191,6 @@ export namespace mockApis { * @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 From b9d90a7140cccf4c47168d954212133bbd2326fd Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 7 Feb 2026 11:30:35 +0100 Subject: [PATCH 12/23] frontend-test-utils: review and type fixes + cleanup Signed-off-by: Patrik Oldsberg --- .changeset/beige-crabs-share.md | 2 +- .changeset/scaffolder-react-test-utils-dep.md | 5 ++ .changeset/scaffolder-test-utils-dep.md | 5 ++ .../utility-apis/05-testing.md | 5 +- packages/frontend-test-utils/report.api.md | 33 ++++++------ .../src/apis/AlertApi/MockAlertApi.test.ts | 22 ++++---- .../frontend-test-utils/src/apis/ApiMock.ts | 34 ------------ .../apis/{utils.ts => MockWithApiFactory.ts} | 16 ------ .../apis/StorageApi/MockStorageApi.test.ts | 2 - .../src/apis/TestApiProvider.tsx | 54 +++++++------------ .../frontend-test-utils/src/apis/index.ts | 11 ++-- .../frontend-test-utils/src/apis/mockApis.ts | 20 ++++++- .../src/app/createExtensionTester.tsx | 5 +- .../src/app/renderInTestApp.tsx | 10 ++-- .../src/app/renderTestApp.tsx | 10 ++-- packages/frontend-test-utils/src/index.ts | 3 -- 16 files changed, 94 insertions(+), 143 deletions(-) create mode 100644 .changeset/scaffolder-react-test-utils-dep.md create mode 100644 .changeset/scaffolder-test-utils-dep.md delete mode 100644 packages/frontend-test-utils/src/apis/ApiMock.ts rename packages/frontend-test-utils/src/apis/{utils.ts => MockWithApiFactory.ts} (87%) diff --git a/.changeset/beige-crabs-share.md b/.changeset/beige-crabs-share.md index 00d8c2c743..6a1a42ffd8 100644 --- a/.changeset/beige-crabs-share.md +++ b/.changeset/beige-crabs-share.md @@ -2,4 +2,4 @@ '@backstage/frontend-test-utils': minor --- -**BREAKING**: Removed the `TestApiRegistry` class, use `TestApiProvider` directory instead, storing resused APIs in an a variable instead, e.g. `const apis = [...] as const`. +**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/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/docs/frontend-system/utility-apis/05-testing.md b/docs/frontend-system/utility-apis/05-testing.md index fc92eb2abb..522b7c2530 100644 --- a/docs/frontend-system/utility-apis/05-testing.md +++ b/docs/frontend-system/utility-apis/05-testing.md @@ -32,14 +32,15 @@ Call `.mock()` to get an instance where every method is a `jest.fn()`. You can o ```ts import { mockApis } from '@backstage/frontend-test-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; -const catalogApi = mockApis.permission.mock({ +const permissionApi = mockApis.permission.mock({ authorize: async () => ({ result: AuthorizeResult.ALLOW }), }); // ... exercise the component ... -expect(catalogApi.authorize).toHaveBeenCalledTimes(1); +expect(permissionApi.authorize).toHaveBeenCalledTimes(1); ``` ## Providing mock APIs in tests diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index bef42bc9e0..c99007a6df 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -8,7 +8,6 @@ import { AlertMessage } from '@backstage/frontend-plugin-api'; import { AnalyticsApi } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/frontend-plugin-api'; import { ApiFactory } from '@backstage/frontend-plugin-api'; -import { ApiHolder } 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'; @@ -39,7 +38,6 @@ import { IdentityApi } from '@backstage/frontend-plugin-api'; import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; -import { JSX as JSX_2 } from 'react/jsx-runtime'; import { Observable } from '@backstage/types'; import { PermissionApi } from '@backstage/plugin-permission-react'; import { ReactNode } from 'react'; @@ -546,13 +544,13 @@ export type MockWithApiFactory = TApi & { export { registerMswTestHooks }; // @public -export function renderInTestApp( +export function renderInTestApp( element: JSX.Element, options?: TestAppOptions, ): RenderResult; // @public -export function renderTestApp( +export function renderTestApp( options?: RenderTestAppOptions, ): RenderResult; @@ -565,24 +563,27 @@ export type RenderTestAppOptions = { mountedRoutes?: { [path: string]: RouteRef; }; - apis?: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis?: readonly [...TestApiPairs]; }; // @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 | MockWithApiFactory[]), - ]; + apis: readonly [...TestApiPairs]; children: ReactNode; }; @@ -594,9 +595,7 @@ export type TestAppOptions = { config?: JsonObject; features?: FrontendFeature[]; initialRouteEntries?: string[]; - apis?: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis?: readonly [...TestApiPairs]; }; export { withLogCollector }; diff --git a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts index 4e44d4281c..ddfbacd3b9 100644 --- a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts +++ b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts @@ -46,22 +46,26 @@ describe('MockAlertApi', () => { expect(api.getAlerts()).toHaveLength(0); }); - it('should notify observers', done => { + it('should notify observers', async () => { 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(); - } - }, + 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 () => { diff --git a/packages/frontend-test-utils/src/apis/ApiMock.ts b/packages/frontend-test-utils/src/apis/ApiMock.ts deleted file mode 100644 index b5ee6f242b..0000000000 --- a/packages/frontend-test-utils/src/apis/ApiMock.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2024 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 } from '@backstage/frontend-plugin-api'; -import { mockApiFactorySymbol } from './utils'; - -/** - * 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 = { - factory: ApiFactory; - [mockApiFactorySymbol]: ApiFactory; -} & { - [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return - ? TApi[Key] & jest.MockInstance - : TApi[Key]; -}; diff --git a/packages/frontend-test-utils/src/apis/utils.ts b/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts similarity index 87% rename from packages/frontend-test-utils/src/apis/utils.ts rename to packages/frontend-test-utils/src/apis/MockWithApiFactory.ts index b6f6af6753..55bb7653e5 100644 --- a/packages/frontend-test-utils/src/apis/utils.ts +++ b/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts @@ -33,22 +33,6 @@ export const mockApiFactorySymbol = Symbol.for('@backstage/mock-api'); */ export type MockApiFactorySymbol = typeof mockApiFactorySymbol; -/** - * 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 = { - factory: ApiFactory; - [mockApiFactorySymbol]: ApiFactory; -} & { - [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return - ? TApi[Key] & jest.MockInstance - : TApi[Key]; -}; - /** * Type for an API instance that has been marked as a mock API. * diff --git a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts index cdae96d5e4..34908f561a 100644 --- a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts @@ -30,7 +30,6 @@ describe('WebStorage Storage API', () => { key: 'myfakekey', presence: 'absent', value: undefined, - newValue: undefined, }); }); @@ -135,7 +134,6 @@ describe('WebStorage Storage API', () => { key: 'correctKey', presence: 'absent', value: undefined, - newValue: undefined, }); }); diff --git a/packages/frontend-test-utils/src/apis/TestApiProvider.tsx b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx index 90dd53f61a..10615446be 100644 --- a/packages/frontend-test-utils/src/apis/TestApiProvider.tsx +++ b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx @@ -18,44 +18,30 @@ 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 './utils'; +import { + getMockApiFactory, + type MockWithApiFactory, +} from './MockWithApiFactory'; /** - * Helper type for representing an API reference paired with a partial implementation. - * @ignore - */ -export type TestApiProviderPropsApiPair = TApi extends infer TImpl - ? readonly [ApiRef, Partial] - : never; - -/** - * Helper type for representing an array of API reference pairs. - * @ignore - */ -export type TestApiProviderPropsApiPairs = { - [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; -}; - -/** - * Shorter alias for TestApiProviderPropsApiPairs for use in function signatures. + * 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 TestApiPairs = TestApiProviderPropsApiPairs; +export type TestApiPair = + | readonly [ApiRef, TApi extends infer TImpl ? Partial : never] + | MockWithApiFactory; /** - * Type for entries that can be passed to TestApiProvider. - * Can be either a traditional [apiRef, implementation] tuple or a mock API instance - * marked with the mockApiFactorySymbol. - * - * @internal + * Represents an array of mock API implementation. + * @public */ -export type TestApiProviderEntry = - | readonly [ApiRef, any] - | MockWithApiFactory; +export type TestApiPairs = { + [TIndex in keyof TApiPairs]: TestApiPair; +}; /** @internal */ export function resolveTestApiEntries( - apis: readonly (TestApiProviderEntry | readonly [ApiRef, any])[], + apis: readonly TestApiPairs[], ): ApiHolder { const apiMap = new Map(); @@ -80,9 +66,7 @@ export function resolveTestApiEntries( * @public */ export type TestApiProviderProps = { - apis: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis: readonly [...TestApiPairs]; children: ReactNode; }; @@ -131,13 +115,13 @@ export type TestApiProviderProps = { * * @public */ -export const TestApiProvider = ( - props: TestApiProviderProps, -) => { +export function TestApiProvider( + props: TestApiProviderProps, +): JSX.Element { return ( ); -}; +} diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 1a3ce150c9..3bad319786 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -14,21 +14,18 @@ * limitations under the License. */ -export { mockApis } from './mockApis'; +export { type ApiMock, mockApis } from './mockApis'; export { type MockApiFactorySymbol, - type ApiMock, type MockWithApiFactory, attachMockApiFactory, -} from './utils'; +} from './MockWithApiFactory'; export { TestApiProvider, - type TestApiProviderPropsApiPair, - type TestApiProviderPropsApiPairs, + type TestApiProviderProps, + type TestApiPair, type TestApiPairs, - type TestApiProviderEntry, } from './TestApiProvider'; -export type { TestApiProviderProps } from './TestApiProvider'; /** * Mock API classes are exported as types only to prevent direct instantiation. diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index 01def834e2..0b0bf18cf4 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -34,6 +34,7 @@ import { type IdentityApi, type StorageApi, type TranslationApi, + ApiFactory, } from '@backstage/frontend-plugin-api'; import { permissionApiRef, @@ -57,11 +58,26 @@ import { MockStorageApi } from './StorageApi'; import { MockPermissionApi } from './PermissionApi'; import { MockTranslationApi } from './TranslationApi'; import { - ApiMock, mockWithApiFactory, mockApiFactorySymbol, type MockWithApiFactory, -} from './utils'; +} 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 = { + factory: ApiFactory; + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; /** @internal */ function simpleMock( diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index ddceab52bd..3a373b3b3a 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -38,8 +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 { type TestApiPairs } from '../apis'; -import { resolveTestApiEntries } from '../apis/TestApiProvider'; +import { resolveTestApiEntries, TestApiPairs } from '../apis/TestApiProvider'; /** * Represents a snapshot of an extension in the app tree. @@ -97,7 +96,7 @@ export class ExtensionTester { /** @internal */ static forSubject< T extends ExtensionDefinitionParameters, - TApiPairs extends any[], + const TApiPairs extends any[], >( subject: ExtensionDefinition, options?: { diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index 79bab70653..10b36b68b4 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -36,10 +36,10 @@ import { } from '@backstage/frontend-plugin-api'; import { RouterBlueprint } from '@backstage/plugin-app-react'; import appPlugin from '@backstage/plugin-app'; -import { type TestApiProviderPropsApiPairs } from '../apis'; -import { getMockApiFactory, type MockWithApiFactory } from '../apis/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' }, @@ -97,9 +97,7 @@ export type TestAppOptions = { * }) * ``` */ - apis?: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis?: readonly [...TestApiPairs]; }; const NavItem = (props: { @@ -166,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 { diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx index 78efb05a41..858ad462f4 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -33,10 +33,10 @@ 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 TestApiProviderPropsApiPairs } from '../apis'; -import { getMockApiFactory, type MockWithApiFactory } from '../apis/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' }, @@ -99,9 +99,7 @@ export type RenderTestAppOptions = { * }) * ``` */ - apis?: readonly [ - ...(TestApiProviderPropsApiPairs | MockWithApiFactory[]), - ]; + apis?: readonly [...TestApiPairs]; }; const appPluginOverride = appPlugin.withOverrides({ @@ -118,7 +116,7 @@ const appPluginOverride = appPlugin.withOverrides({ * * @public */ -export function renderTestApp( +export function renderTestApp( options?: RenderTestAppOptions, ): RenderResult { const extensions = [...(options?.extensions ?? [])]; diff --git a/packages/frontend-test-utils/src/index.ts b/packages/frontend-test-utils/src/index.ts index 03f08ab929..b4e7a4e007 100644 --- a/packages/frontend-test-utils/src/index.ts +++ b/packages/frontend-test-utils/src/index.ts @@ -23,9 +23,6 @@ export * from './apis'; export * from './app'; -// Explicit export to satisfy API Extractor -export type { TestApiPairs } from './apis'; - export { withLogCollector } from '@backstage/test-utils'; export { registerMswTestHooks } from '@backstage/test-utils'; From 3f19d5ec3217a3f21e6f74a94bef2eb563d46703 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 14:20:57 +0100 Subject: [PATCH 13/23] frontend-test-utils: review fixes for readonly fields and config constructor Signed-off-by: Patrik Oldsberg Co-authored-by: Cursor --- .../vale/config/vocabularies/Backstage/accept.txt | 2 ++ .../src/apis/AlertApi/MockAlertApi.ts | 6 +++--- .../src/apis/ConfigApi/MockConfigApi.test.ts | 12 +++++++----- .../src/apis/ConfigApi/MockConfigApi.ts | 4 ++-- .../src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts | 9 ++++++--- .../src/apis/TranslationApi/MockTranslationApi.ts | 6 +++--- packages/frontend-test-utils/src/apis/mockApis.ts | 2 +- 7 files changed, 24 insertions(+), 17 deletions(-) diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index cb569f01b7..66ed469f0d 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -584,3 +584,5 @@ zsh resizable enums LLMs +Passthrough +passthrough diff --git a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts index d07d9602b2..305a527b87 100644 --- a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts +++ b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts @@ -31,8 +31,8 @@ import ObservableImpl from 'zen-observable'; * ``` */ export class MockAlertApi implements AlertApi { - private alerts: AlertMessage[] = []; - private observers = new Set<(alert: AlertMessage) => void>(); + private readonly alerts: AlertMessage[] = []; + private readonly observers = new Set<(alert: AlertMessage) => void>(); post(alert: AlertMessage) { this.alerts.push(alert); @@ -62,7 +62,7 @@ export class MockAlertApi implements AlertApi { * Clear all collected alerts. */ clearAlerts(): void { - this.alerts = []; + this.alerts.length = 0; } /** diff --git a/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts index 972733d44d..c3157a8aff 100644 --- a/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts +++ b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts @@ -19,12 +19,14 @@ import { MockConfigApi } from './MockConfigApi'; describe('MockConfigApi', () => { it('is able to read some basic config', () => { const mock = new MockConfigApi({ - app: { - title: 'Hello', + data: { + app: { + title: 'Hello', + }, + x: 1, + y: false, + z: [{ a: 3 }], }, - x: 1, - y: false, - z: [{ a: 3 }], }); expect(mock.getString('app.title')).toEqual('Hello'); diff --git a/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts index 372c295c02..349eee0d3a 100644 --- a/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts +++ b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts @@ -26,7 +26,7 @@ import { ConfigApi } from '@backstage/core-plugin-api'; * @example * ```tsx * const mockConfig = new MockConfigApi({ - * app: { baseUrl: 'https://example.com' }, + * data: { app: { baseUrl: 'https://example.com' } }, * }); * * const rendered = await renderInTestApp( @@ -40,7 +40,7 @@ export class MockConfigApi implements ConfigApi { private readonly config: ConfigReader; // NOTE: not extending in order to avoid inheriting the static `.fromConfigs` - constructor(data: JsonObject) { + constructor({ data }: { data: JsonObject }) { this.config = new ConfigReader(data); } diff --git a/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts index ef0eef3159..5421687c65 100644 --- a/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts +++ b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts @@ -46,8 +46,8 @@ export interface MockFeatureFlagsApiOptions { * ``` */ export class MockFeatureFlagsApi implements FeatureFlagsApi { - private registeredFlags: FeatureFlag[] = []; - private states: Map; + private readonly registeredFlags: FeatureFlag[] = []; + private readonly states: Map; constructor(options?: MockFeatureFlagsApiOptions) { this.states = new Map(Object.entries(options?.initialStates ?? {})); @@ -73,7 +73,10 @@ export class MockFeatureFlagsApi implements FeatureFlagsApi { this.states.set(name, state); } } else { - this.states = new Map(Object.entries(options.states)); + this.states.clear(); + for (const [name, state] of Object.entries(options.states)) { + this.states.set(name, state); + } } } diff --git a/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts index bce96dd50b..d5fa29ea73 100644 --- a/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts +++ b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts @@ -64,9 +64,9 @@ export class MockTranslationApi implements TranslationApi { return new MockTranslationApi(i18n, interpolator); } - #i18n: I18n; - #interpolator: JsxInterpolator; - #registeredRefs = new Set(); + readonly #i18n: I18n; + readonly #interpolator: JsxInterpolator; + readonly #registeredRefs = new Set(); private constructor(i18n: I18n, interpolator: JsxInterpolator) { this.#i18n = i18n; diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index 0b0bf18cf4..03d4ed32e0 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -287,7 +287,7 @@ export namespace mockApis { export function config(options?: { data?: JsonObject; }): MockConfigApi & MockWithApiFactory { - const instance = new MockConfigApi(options?.data ?? {}); + const instance = new MockConfigApi({ data: options?.data ?? {} }); return mockWithApiFactory(configApiRef, instance) as MockConfigApi & MockWithApiFactory; } From 7ee7f122a871a8989ec8150f85cbd3c2dcfa3bce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 17:21:24 +0100 Subject: [PATCH 14/23] frontend-test-utils: adjust fetch mock + add createApiMock Signed-off-by: Patrik Oldsberg --- ...tend-test-utils-mock-apis-and-shorthand.md | 2 + .../utility-apis/05-testing.md | 41 +++- packages/frontend-test-utils/package.json | 1 - packages/frontend-test-utils/report.api.md | 180 +++--------------- .../src/apis/FetchApi/MockFetchApi.ts | 8 +- .../src/apis/createApiMock.test.ts | 86 +++++++++ .../src/apis/createApiMock.ts | 87 +++++++++ .../frontend-test-utils/src/apis/index.ts | 3 +- .../frontend-test-utils/src/apis/mockApis.ts | 70 ++----- yarn.lock | 1 - 10 files changed, 255 insertions(+), 224 deletions(-) create mode 100644 packages/frontend-test-utils/src/apis/createApiMock.test.ts create mode 100644 packages/frontend-test-utils/src/apis/createApiMock.ts diff --git a/.changeset/frontend-test-utils-mock-apis-and-shorthand.md b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md index 546d2e102c..c864cba776 100644 --- a/.changeset/frontend-test-utils-mock-apis-and-shorthand.md +++ b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md @@ -17,3 +17,5 @@ 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/docs/frontend-system/utility-apis/05-testing.md b/docs/frontend-system/utility-apis/05-testing.md index 522b7c2530..70b8ca70e9 100644 --- a/docs/frontend-system/utility-apis/05-testing.md +++ b/docs/frontend-system/utility-apis/05-testing.md @@ -124,28 +124,53 @@ renderTestApp({ }); ``` -### Creating your own mock APIs with `attachMockApiFactory` +### Creating your own mock APIs -If you maintain a plugin that exposes a utility API, you can use `attachMockApiFactory` to create mock instances that can be passed directly to test utilities: +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 } from '@backstage/frontend-test-utils'; +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 }): MyApi { - const instance: MyApi = { + return attachMockApiFactory(myApiRef, { greet: async () => options?.greeting ?? 'Hello!', - }; - return attachMockApiFactory(myApiRef, instance); + }); +} + +// 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 like the built-in mocks: +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 @@ -160,7 +185,7 @@ The table below lists all core APIs available through the `mockApis` namespace. | `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 `cross-fetch`; supports identity injection and plugin protocol | +| `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 | diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index e4e03602d2..028f9d1eff 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -43,7 +43,6 @@ "@backstage/test-utils": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", - "cross-fetch": "^4.0.0", "i18next": "^22.4.15", "zen-observable": "^0.10.0", "zod": "^3.25.76" diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index c99007a6df..cbf48cbb17 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -15,7 +15,6 @@ 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 crossFetch from 'cross-fetch'; import { DiscoveryApi } from '@backstage/frontend-plugin-api'; import { DiscoveryApi as DiscoveryApi_2 } from '@backstage/core-plugin-api'; import { ErrorApi } from '@backstage/core-plugin-api'; @@ -55,7 +54,6 @@ import { withLogCollector } from '@backstage/test-utils'; // @public export type ApiMock = { - factory: ApiFactory; [mockApiFactorySymbol]: ApiFactory; } & { [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return @@ -71,6 +69,12 @@ export function attachMockApiFactory( [mockApiFactorySymbol]: ApiFactory; }; +// @public +export function createApiMock( + apiRef: ApiRef, + mockFactory: () => jest.Mocked, +): (partialImpl?: Partial) => ApiMock; + // @public (undocumented) export function createExtensionTester< T extends ExtensionDefinitionParameters, @@ -170,75 +174,23 @@ export namespace mockApis { export function alert(): MockWithApiFactory; export namespace alert { const mock: ( - partialImpl?: - | Partial<{ - post: jest.Mock; - alert$: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - post: jest.Mock; - alert$: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function analytics(): MockAnalyticsApi & MockWithApiFactory; export namespace analytics { const // (undocumented) mock: ( - partialImpl?: - | Partial<{ - captureEvent: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - captureEvent: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function config(options?: { data?: JsonObject; }): MockConfigApi & MockWithApiFactory; export namespace config { const // (undocumented) - mock: ( - partialImpl?: - | Partial<{ - has: jest.Mock; - keys: jest.Mock; - get: jest.Mock; - getOptional: jest.Mock; - getConfig: jest.Mock; - getOptionalConfig: jest.Mock; - getConfigArray: jest.Mock; - getOptionalConfigArray: jest.Mock; - getNumber: jest.Mock; - getOptionalNumber: jest.Mock; - getBoolean: jest.Mock; - getOptionalBoolean: jest.Mock; - getString: jest.Mock; - getOptionalString: jest.Mock; - getStringArray: jest.Mock; - getOptionalStringArray: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - has: jest.Mock; - keys: jest.Mock; - get: jest.Mock; - getOptional: jest.Mock; - getConfig: jest.Mock; - getOptionalConfig: jest.Mock; - getConfigArray: jest.Mock; - getOptionalConfigArray: jest.Mock; - getNumber: jest.Mock; - getOptionalNumber: jest.Mock; - getBoolean: jest.Mock; - getOptionalBoolean: jest.Mock; - getString: jest.Mock; - getOptionalString: jest.Mock; - getStringArray: jest.Mock; - getOptionalStringArray: jest.Mock; - }>; + mock: (partialImpl?: Partial | undefined) => ApiMock; } export function discovery(options?: { baseUrl?: string; @@ -246,14 +198,8 @@ export namespace mockApis { export namespace discovery { const // (undocumented) mock: ( - partialImpl?: - | Partial<{ - getBaseUrl: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - getBaseUrl: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function error( options?: MockErrorApiOptions, @@ -261,36 +207,16 @@ export namespace mockApis { export namespace error { const // (undocumented) mock: ( - partialImpl?: - | Partial<{ - post: jest.Mock; - error$: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - post: jest.Mock; - error$: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function featureFlags( options?: MockFeatureFlagsApiOptions, ): MockWithApiFactory; export namespace featureFlags { 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; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function fetch( options?: MockFetchApiOptions, @@ -298,14 +224,8 @@ export namespace mockApis { export namespace fetch { const // (undocumented) mock: ( - partialImpl?: - | Partial<{ - fetch: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - fetch: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function identity(options?: { userEntityRef?: string; @@ -318,20 +238,8 @@ export namespace mockApis { export namespace identity { const // (undocumented) mock: ( - partialImpl?: - | Partial<{ - getBackstageIdentity: jest.Mock; - getCredentials: jest.Mock; - getProfileInfo: jest.Mock; - signOut: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - getBackstageIdentity: jest.Mock; - getCredentials: jest.Mock; - getProfileInfo: jest.Mock; - signOut: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function permission(options?: { authorize?: @@ -344,14 +252,8 @@ export namespace mockApis { export namespace permission { const // (undocumented) mock: ( - partialImpl?: - | Partial<{ - authorize: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - authorize: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function storage(options?: { data?: JsonObject; @@ -359,43 +261,21 @@ export namespace mockApis { export namespace storage { const // (undocumented) mock: ( - partialImpl?: - | Partial<{ - forBucket: jest.Mock; - snapshot: jest.Mock; - set: jest.Mock; - remove: jest.Mock; - observe$: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - forBucket: jest.Mock; - snapshot: jest.Mock; - set: jest.Mock; - remove: jest.Mock; - observe$: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } export function translation(): MockTranslationApi & MockWithApiFactory; export namespace translation { const mock: ( - partialImpl?: - | Partial<{ - getTranslation: jest.Mock; - translation$: jest.Mock; - }> - | undefined, - ) => ApiMock<{ - getTranslation: jest.Mock; - translation$: jest.Mock; - }>; + partialImpl?: Partial | undefined, + ) => ApiMock; } } // @public export class MockConfigApi implements ConfigApi { - constructor(data: JsonObject); + constructor({ data }: { data: JsonObject }); get(key?: string): T; getBoolean(key: string): boolean; getConfig(key: string): Config; @@ -459,12 +339,12 @@ export interface MockFeatureFlagsApiOptions { // @public export class MockFetchApi implements FetchApi { constructor(options?: MockFetchApiOptions); - get fetch(): typeof crossFetch; + get fetch(): typeof fetch; } // @public export interface MockFetchApiOptions { - baseImplementation?: undefined | 'none' | typeof crossFetch; + baseImplementation?: undefined | 'none' | typeof fetch; injectIdentityAuth?: | undefined | { diff --git a/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts index 661d202379..b61ca680d1 100644 --- a/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts +++ b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts @@ -24,8 +24,6 @@ import { FetchApi, IdentityApi, } from '@backstage/core-plugin-api'; -import crossFetch, { Response } from 'cross-fetch'; - /** * The options given when constructing a {@link MockFetchApi}. * @@ -47,7 +45,7 @@ export interface MockFetchApiOptions { * `jest.fn()`, if you want to use a custom implementation or to just track * and assert on calls. */ - baseImplementation?: undefined | 'none' | typeof crossFetch; + baseImplementation?: undefined | 'none' | typeof fetch; /** * Add translation from `plugin://` URLs to concrete http(s) URLs, basically @@ -103,7 +101,7 @@ export class MockFetchApi implements FetchApi { } /** {@inheritdoc @backstage/core-plugin-api#FetchApi.fetch} */ - get fetch(): typeof crossFetch { + get fetch(): typeof fetch { return this.implementation.fetch; } } @@ -124,7 +122,7 @@ function build(options?: MockFetchApiOptions): FetchApi { function baseImplementation( options: MockFetchApiOptions | undefined, -): typeof crossFetch { +): typeof fetch { const implementation = options?.baseImplementation; if (!implementation) { // Return a wrapper that evaluates global.fetch at call time, not construction time. 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 3bad319786..5be2398595 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -14,7 +14,8 @@ * limitations under the License. */ -export { type ApiMock, mockApis } from './mockApis'; +export { mockApis } from './mockApis'; +export { createApiMock, type ApiMock } from './createApiMock'; export { type MockApiFactorySymbol, type MockWithApiFactory, diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index 03d4ed32e0..efbc56bde8 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -18,7 +18,6 @@ import { alertApiRef, analyticsApiRef, configApiRef, - createApiFactory, discoveryApiRef, errorApiRef, fetchApiRef, @@ -34,7 +33,6 @@ import { type IdentityApi, type StorageApi, type TranslationApi, - ApiFactory, } from '@backstage/frontend-plugin-api'; import { permissionApiRef, @@ -59,53 +57,9 @@ import { MockPermissionApi } from './PermissionApi'; import { MockTranslationApi } from './TranslationApi'; import { mockWithApiFactory, - mockApiFactorySymbol, type MockWithApiFactory, } 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 = { - factory: ApiFactory; - [mockApiFactorySymbol]: ApiFactory; -} & { - [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return - ? TApi[Key] & jest.MockInstance - : TApi[Key]; -}; - -/** @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; - } - } - } - const factory = createApiFactory({ - api: ref, - deps: {}, - factory: () => mock, - }); - const apiMock = Object.assign(mock, { factory }) as ApiMock; - // Set the mock API symbol to the same factory - (apiMock as any)[mockApiFactorySymbol] = factory; - return apiMock; - }; -} +import { createApiMock } from './createApiMock'; /** * Mock implementations of the core utility APIs, to be used in tests. @@ -172,7 +126,7 @@ export namespace mockApis { * * @public */ - export const mock = simpleMock(alertApiRef, () => ({ + export const mock = createApiMock(alertApiRef, () => ({ post: jest.fn(), alert$: jest.fn(), })); @@ -215,7 +169,7 @@ export namespace mockApis { * * @public */ - export const mock = simpleMock(featureFlagsApiRef, () => ({ + export const mock = createApiMock(featureFlagsApiRef, () => ({ registerFlag: jest.fn(), getRegisteredFlags: jest.fn(), isActive: jest.fn(), @@ -241,7 +195,7 @@ export namespace mockApis { * @public */ export namespace analytics { - export const mock = simpleMock(analyticsApiRef, () => ({ + export const mock = createApiMock(analyticsApiRef, () => ({ captureEvent: jest.fn(), })); } @@ -273,7 +227,7 @@ export namespace mockApis { * * @public */ - export const mock = simpleMock(translationApiRef, () => ({ + export const mock = createApiMock(translationApiRef, () => ({ getTranslation: jest.fn(), translation$: jest.fn(), })); @@ -298,7 +252,7 @@ export namespace mockApis { * @public */ export namespace config { - export const mock = simpleMock(configApiRef, () => ({ + export const mock = createApiMock(configApiRef, () => ({ has: jest.fn(), keys: jest.fn(), get: jest.fn(), @@ -342,7 +296,7 @@ export namespace mockApis { * @public */ export namespace discovery { - export const mock = simpleMock(discoveryApiRef, () => ({ + export const mock = createApiMock(discoveryApiRef, () => ({ getBaseUrl: jest.fn(), })); } @@ -390,7 +344,7 @@ export namespace mockApis { * @public */ export namespace identity { - export const mock = simpleMock(identityApiRef, () => ({ + export const mock = createApiMock(identityApiRef, () => ({ getBackstageIdentity: jest.fn(), getCredentials: jest.fn(), getProfileInfo: jest.fn(), @@ -427,7 +381,7 @@ export namespace mockApis { * @public */ export namespace permission { - export const mock = simpleMock(permissionApiRef, () => ({ + export const mock = createApiMock(permissionApiRef, () => ({ authorize: jest.fn(), })); } @@ -451,7 +405,7 @@ export namespace mockApis { * @public */ export namespace storage { - export const mock = simpleMock(storageApiRef, () => ({ + export const mock = createApiMock(storageApiRef, () => ({ forBucket: jest.fn(), snapshot: jest.fn(), set: jest.fn(), @@ -479,7 +433,7 @@ export namespace mockApis { * @public */ export namespace error { - export const mock = simpleMock(errorApiRef, () => ({ + export const mock = createApiMock(errorApiRef, () => ({ post: jest.fn(), error$: jest.fn(), })); @@ -504,7 +458,7 @@ export namespace mockApis { * @public */ export namespace fetch { - export const mock = simpleMock(fetchApiRef, () => ({ + export const mock = createApiMock(fetchApiRef, () => ({ fetch: jest.fn(), })); } diff --git a/yarn.lock b/yarn.lock index a5ff49f678..9dfe77cd00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3966,7 +3966,6 @@ __metadata: "@testing-library/jest-dom": "npm:^6.0.0" "@types/react": "npm:^18.0.0" "@types/zen-observable": "npm:^0.8.0" - cross-fetch: "npm:^4.0.0" i18next: "npm:^22.4.15" msw: "npm:^2.0.0" react: "npm:^18.0.2" From e6c336bc761dc659649bab6c5770af6d080d472c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 19:32:10 +0100 Subject: [PATCH 15/23] catalog-react: switch to createApiMock Signed-off-by: Patrik Oldsberg --- .../src/testUtils/catalogApiMock.ts | 36 ++----------------- 1 file changed, 3 insertions(+), 33 deletions(-) diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts index dd225adc54..007345de22 100644 --- a/plugins/catalog-react/src/testUtils/catalogApiMock.ts +++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts @@ -14,47 +14,17 @@ * 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, + createApiMock, attachMockApiFactory, type MockWithApiFactory, } 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; - } - } - } - const factory = createApiFactory({ - api: ref, - deps: {}, - factory: () => mock, - }); - const marked = attachMockApiFactory(ref, mock); - return Object.assign(marked, { factory }) as ApiMock; - }; -} - /** * Creates a fake catalog client that handles entities in memory storage. Note * that this client may be severely limited in functionality, and advanced @@ -92,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(), From 2d77cacd841ec506c4fe565b1f30889c6aa3e50f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 19:44:20 +0100 Subject: [PATCH 16/23] frontend-test-utils: add @types/jest peer dep Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/package.json | 5 +++++ yarn.lock | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 028f9d1eff..d0398d7256 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -50,6 +50,7 @@ "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", @@ -59,12 +60,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/yarn.lock b/yarn.lock index 9dfe77cd00..39fb295f1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3964,6 +3964,7 @@ __metadata: "@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" @@ -3975,11 +3976,14 @@ __metadata: 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 From 6bc82ab1232b30a7eb4db8aa0617f84417d1aabb Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 19:50:45 +0100 Subject: [PATCH 17/23] accepts: cleanup duplicate casing Signed-off-by: Patrik Oldsberg --- .../config/vocabularies/Backstage/accept.txt | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 66ed469f0d..bc410a842e 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 @@ -584,5 +572,3 @@ zsh resizable enums LLMs -Passthrough -passthrough From 3843732b7333f75702c77e0ba779d51e700eb717 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 19:53:32 +0100 Subject: [PATCH 18/23] scaffolder-react: update useTemplateSchema test to use new test utils Signed-off-by: Patrik Oldsberg --- .../src/next/hooks/useTemplateSchema.test.tsx | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx index b2cf94edbe..b51e3ff147 100644 --- a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx @@ -15,10 +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', () => { @@ -56,7 +55,7 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( - + {children} ), @@ -123,10 +122,7 @@ describe('useTemplateSchema', () => { wrapper: ({ children }: PropsWithChildren<{}>) => ( false) }), - ], + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), ]} > {children} @@ -172,10 +168,7 @@ describe('useTemplateSchema', () => { wrapper: ({ children }: PropsWithChildren<{}>) => ( true) }), - ], + mockApis.featureFlags.mock({ isActive: jest.fn(() => true) }), ]} > {children} @@ -228,10 +221,7 @@ describe('useTemplateSchema', () => { wrapper: ({ children }: PropsWithChildren<{}>) => ( false) }), - ], + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), ]} > {children} @@ -271,10 +261,7 @@ describe('useTemplateSchema', () => { wrapper: ({ children }: PropsWithChildren<{}>) => ( false) }), - ], + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), ]} > {children} @@ -380,10 +367,7 @@ describe('useTemplateSchema', () => { wrapper: ({ children }: PropsWithChildren<{}>) => ( false) }), - ], + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), ]} > {children} From a7d4a3109cff73f5633526833289cb3555249585 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 20:37:36 +0100 Subject: [PATCH 19/23] frontend-test-utils: consistent internal types for test api pairs Signed-off-by: Patrik Oldsberg --- packages/frontend-test-utils/src/apis/TestApiProvider.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/frontend-test-utils/src/apis/TestApiProvider.tsx b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx index 10615446be..215e8c8a71 100644 --- a/packages/frontend-test-utils/src/apis/TestApiProvider.tsx +++ b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx @@ -40,8 +40,8 @@ export type TestApiPairs = { }; /** @internal */ -export function resolveTestApiEntries( - apis: readonly TestApiPairs[], +export function resolveTestApiEntries( + apis: readonly [...TestApiPairs], ): ApiHolder { const apiMap = new Map(); From df59ee6a82575cf2abdc013ff8533ba1a443dca1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 20:58:08 +0100 Subject: [PATCH 20/23] repo-tools/type-deps: detect ambient jest usage in types Signed-off-by: Patrik Oldsberg --- .changeset/repo-tools-type-deps-ambient-jest.md | 5 +++++ .../src/commands/type-deps/type-deps.ts | 16 +++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .changeset/repo-tools-type-deps-ambient-jest.md 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/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]), + ); } /** From 68eb32266db3a122b3f27d742319a42c210d6c51 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 21:16:24 +0100 Subject: [PATCH 21/23] add missing jest peer deps Signed-off-by: Patrik Oldsberg --- .changeset/test-utils-jest-peer-dep.md | 6 ++++++ packages/backend-test-utils/package.json | 8 ++++++++ packages/test-utils/package.json | 4 ++++ yarn.lock | 8 ++++++++ 4 files changed, 26 insertions(+) create mode 100644 .changeset/test-utils-jest-peer-dep.md 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/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/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/yarn.lock b/yarn.lock index 39fb295f1e..cbaa178fa2 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 @@ -7974,11 +7979,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 From 67959bf42ae623ff5723f614eb1c3fbc896dae60 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 9 Feb 2026 22:11:52 +0100 Subject: [PATCH 22/23] catalog-react: move frontend-test-utils to an option peer dep Signed-off-by: Patrik Oldsberg --- plugins/catalog-react/package.json | 6 +++++- yarn.lock | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) 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/yarn.lock b/yarn.lock index cbaa178fa2..b515f5a32e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5444,11 +5444,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 From 8fa6ef3452de0b5c778021c07a8bb7a43ac60701 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 10 Feb 2026 10:29:28 +0100 Subject: [PATCH 23/23] frontend-test-utils: review fixes Signed-off-by: Patrik Oldsberg --- docs/frontend-system/utility-apis/05-testing.md | 2 +- packages/frontend-test-utils/report.api.md | 9 ++------- .../src/apis/StorageApi/MockStorageApi.ts | 17 +++++------------ .../src/apis/StorageApi/index.ts | 1 - packages/frontend-test-utils/src/apis/index.ts | 2 +- .../frontend-test-utils/src/apis/mockApis.ts | 2 +- 6 files changed, 10 insertions(+), 23 deletions(-) diff --git a/docs/frontend-system/utility-apis/05-testing.md b/docs/frontend-system/utility-apis/05-testing.md index 70b8ca70e9..ba1e93e505 100644 --- a/docs/frontend-system/utility-apis/05-testing.md +++ b/docs/frontend-system/utility-apis/05-testing.md @@ -139,7 +139,7 @@ import { import { myApiRef, type MyApi } from '@internal/plugin-example-react'; // Fake instance with real behavior -export function myApiMock(options?: { greeting?: string }): MyApi { +export function myApiMock(options?: { greeting?: string }) { return attachMockApiFactory(myApiRef, { greet: async () => options?.greeting ?? 'Hello!', }); diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index cbf48cbb17..748eee15d3 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -234,7 +234,7 @@ export namespace mockApis { email?: string; displayName?: string; picture?: string; - }): IdentityApi & MockWithApiFactory; + }): MockWithApiFactory; export namespace identity { const // (undocumented) mock: ( @@ -376,7 +376,7 @@ export class MockPermissionApi implements PermissionApi { // @public export class MockStorageApi implements StorageApi { // (undocumented) - static create(data?: MockStorageBucket): MockStorageApi; + static create(data?: JsonObject): MockStorageApi; // (undocumented) forBucket(name: string): StorageApi; // (undocumented) @@ -391,11 +391,6 @@ export class MockStorageApi implements StorageApi { snapshot(key: string): StorageValueSnapshot; } -// @public -export type MockStorageBucket = { - [key: string]: any; -}; - // @public export class MockTranslationApi implements TranslationApi { // (undocumented) diff --git a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts index 2585264259..b4cf762599 100644 --- a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts @@ -15,16 +15,9 @@ */ import { StorageApi, StorageValueSnapshot } from '@backstage/core-plugin-api'; -import { JsonValue, Observable } from '@backstage/types'; +import { JsonObject, JsonValue, Observable } from '@backstage/types'; import ObservableImpl from 'zen-observable'; -/** - * Type for map holding data in {@link MockStorageApi} - * - * @public - */ -export type MockStorageBucket = { [key: string]: any }; - /** * Mock implementation of the {@link core-plugin-api#StorageApi} to be used in tests * @@ -32,20 +25,20 @@ export type MockStorageBucket = { [key: string]: any }; */ export class MockStorageApi implements StorageApi { private readonly namespace: string; - private readonly data: MockStorageBucket; + private readonly data: JsonObject; private readonly bucketStorageApis: Map; private constructor( namespace: string, bucketStorageApis: Map, - data?: MockStorageBucket, + data?: JsonObject, ) { this.namespace = namespace; this.bucketStorageApis = bucketStorageApis; this.data = { ...data }; } - static create(data?: MockStorageBucket) { + 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 } = {}; @@ -83,7 +76,7 @@ export class MockStorageApi implements StorageApi { return { key, presence: 'present', - value: data, + value: data as T, }; } return { diff --git a/packages/frontend-test-utils/src/apis/StorageApi/index.ts b/packages/frontend-test-utils/src/apis/StorageApi/index.ts index a42fbb3b50..6e2ebc6918 100644 --- a/packages/frontend-test-utils/src/apis/StorageApi/index.ts +++ b/packages/frontend-test-utils/src/apis/StorageApi/index.ts @@ -15,4 +15,3 @@ */ export { MockStorageApi } from './MockStorageApi'; -export type { MockStorageBucket } from './MockStorageApi'; diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index 5be2398595..6e09833298 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -78,7 +78,7 @@ export type { MockPermissionApi } from './PermissionApi'; /** * @public */ -export type { MockStorageApi, MockStorageBucket } from './StorageApi'; +export type { MockStorageApi } from './StorageApi'; /** * @public diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts index efbc56bde8..9e3dce1e3a 100644 --- a/packages/frontend-test-utils/src/apis/mockApis.ts +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -313,7 +313,7 @@ export namespace mockApis { email?: string; displayName?: string; picture?: string; - }): IdentityApi & MockWithApiFactory { + }): MockWithApiFactory { const { userEntityRef = 'user:default/test', ownershipEntityRefs = ['user:default/test'],