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"