frontend-test-utils: copy over all mock APIs

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-05 21:50:27 +01:00
parent a20cbb2514
commit 14611301e5
24 changed files with 2294 additions and 61 deletions
@@ -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'",
);
});
});
@@ -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(
* <TestApiProvider apis={[[configApiRef, mockConfig]]}>
* <MyTestedComponent />
* </TestApiProvider>,
* );
* ```
*/
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<T = JsonValue>(key?: string): T {
return this.config.get(key);
}
/** {@inheritdoc @backstage/config#Config.getOptional} */
getOptional<T = JsonValue>(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);
}
}
@@ -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';
@@ -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<T>(promise: Promise<T>): Promise<T | 'not-yet'> {
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',
);
});
});
@@ -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<ErrorWithContext>();
private readonly waiters = new Set<Waiter>();
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<ErrorWithContext> {
return new Promise<ErrorWithContext>((resolve, reject) => {
setTimeout(() => {
reject(new Error('Timed out waiting for error'));
}, timeoutMs);
this.waiters.add({ resolve, pattern });
});
}
}
@@ -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';
@@ -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',
);
});
});
});
@@ -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<DiscoveryApi, 'getBaseUrl'> };
/**
* 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<IdentityApi, 'getCredentials'> };
}
/**
* 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<IdentityApi, 'getCredentials'> =
'token' in options
? { getCredentials: async () => ({ token: options.token }) }
: options.identityApi;
return FetchMiddlewares.injectIdentityAuth({
identityApi: identityApi as IdentityApi,
allowUrl: () => true,
});
}
@@ -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';
@@ -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 });
});
});
@@ -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<EvaluatePermissionResponse> {
return { result: this.requestHandler(request) };
}
}
@@ -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';
@@ -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<void>(resolve => {
storage.observe$<typeof mockData>('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<void>(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<typeof data>('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<any>(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<any>('test', {
foo: {
toJSON() {
return {
bar: 'baz',
};
},
},
});
expect(storage.snapshot('test')).toMatchObject({
presence: 'present',
value: {
foo: {
bar: 'baz',
},
},
});
});
});
@@ -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<string, MockStorageApi>;
private constructor(
namespace: string,
bucketStorageApis: Map<string, MockStorageApi>,
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<T extends JsonValue>(key: string): StorageValueSnapshot<T> {
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<T>(key: string, data: T): Promise<void> {
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<void> {
delete this.data[this.getKeyName(key)];
this.notifyChanges({
key,
presence: 'absent',
value: undefined,
});
}
observe$<T extends JsonValue>(
key: string,
): Observable<StorageValueSnapshot<T>> {
return this.observable.filter(({ key: messageKey }) => messageKey === key);
}
private getKeyName(key: string) {
return `${this.namespace}/${encodeURIComponent(key)}`;
}
private notifyChanges<T extends JsonValue>(message: StorageValueSnapshot<T>) {
for (const subscription of this.subscribers) {
subscription.next(message);
}
}
private subscribers = new Set<
ZenObservable.SubscriptionObserver<StorageValueSnapshot<JsonValue>>
>();
private readonly observable = new ObservableImpl<
StorageValueSnapshot<JsonValue>
>(subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
}
@@ -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';
@@ -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: '<div>' })).toBe('Foo <div>');
expect(
snapshot.t('foo', {
foo: '<div>',
interpolation: { escapeValue: true },
}),
).toBe('Foo &lt;div&gt;');
});
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: <h1>hello</h1> })).toMatchInlineSnapshot(`
<React.Fragment>
=
<h1>
hello
</h1>
</React.Fragment>
`);
expect(snapshot.t('jsx', { replace: { x: <h1>hello</h1> } }))
.toMatchInlineSnapshot(`
<React.Fragment>
=
<h1>
hello
</h1>
</React.Fragment>
`);
expect(
snapshot.t('jsxNested', { replace: { x: { y: { z: <h1>hello</h1> } } } }),
).toMatchInlineSnapshot(`
<React.Fragment>
=
<h1>
hello
</h1>
</React.Fragment>
`);
expect(snapshot.t('jsxDeep', { x: <h1>hello</h1> })).toMatchInlineSnapshot(`
<React.Fragment>
&lt;=
<h1>
hello
</h1>
&gt;
</React.Fragment>
`);
});
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');
});
});
@@ -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<string>();
private constructor(i18n: I18n, interpolator: JsxInterpolator) {
this.#i18n = i18n;
this.#interpolator = interpolator;
}
getTranslation<TMessages extends { [key in string]: string }>(
translationRef: TranslationRef<string, TMessages>,
): TranslationSnapshot<TMessages> {
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<TMessages>(
this.#i18n.getFixedT(null, internalRef.id),
);
return {
ready: true,
t,
};
}
translation$<TMessages extends { [key in string]: string }>(): Observable<
TranslationSnapshot<TMessages>
> {
// No need to implement, getTranslation will always return a ready snapshot
return new ObservableImpl<TranslationSnapshot<TMessages>>(_subscriber => {
return () => {};
});
}
}
@@ -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';
+39 -13
View File
@@ -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';
@@ -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<AnalyticsApi> {
const instance = new MockAnalyticsApi();
return mockWithApiFactory(analyticsApiRef, instance) as MockAnalyticsApi &
MockWithApiFactory<AnalyticsApi>;
}
/**
* 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<TranslationApi> {
const instance = MockTranslationApi.create();
return mockWithApiFactory(
translationApiRef,
instance,
) as MockTranslationApi & MockWithApiFactory<TranslationApi>;
}
/**
* 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<ConfigApi> {
const instance = new MockConfigApi(options?.data ?? {});
return mockWithApiFactory(configApiRef, instance) as MockConfigApi &
MockWithApiFactory<ConfigApi>;
}
/**
* 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<DiscoveryApi> {
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<DiscoveryApi>;
}
/**
* 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<IdentityApi> {
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<IdentityApi>;
}
/**
* 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<PermissionApi> {
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<PermissionApi>;
}
/**
* 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<StorageApi> {
const instance = MockStorageApi.create(options?.data);
return mockWithApiFactory(storageApiRef, instance) as MockStorageApi &
MockWithApiFactory<StorageApi>;
}
/**
* 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<ErrorApi> {
const instance = new MockErrorApi(options);
return mockWithApiFactory(errorApiRef, instance) as MockErrorApi &
MockWithApiFactory<ErrorApi>;
}
/**
* 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<FetchApi> {
const instance = new MockFetchApi(options);
return mockWithApiFactory(fetchApiRef, instance) as MockFetchApi &
MockWithApiFactory<FetchApi>;
}
/**
* Mock helpers for {@link @backstage/core-plugin-api#FetchApi}.
*
* @public
*/
export namespace fetch {
export const mock = simpleMock(fetchApiRef, () => ({
fetch: jest.fn(),
}));
}
}
@@ -63,11 +63,13 @@ export type MockWithApiFactory<TApi> = TApi & {
*
* @internal
*/
export function mockWithApiFactory<TApi>(
export function mockWithApiFactory<TApi, TImpl extends TApi = TApi>(
apiRef: ApiRef<TApi>,
implementation: TApi,
): MockWithApiFactory<TApi> {
const marked = implementation as MockWithApiFactory<TApi>;
implementation: TImpl,
): TImpl & { [mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}> } {
const marked = implementation as TImpl & {
[mockApiFactorySymbol]: ApiFactory<TApi, TApi, {}>;
};
(marked as any)[mockApiFactorySymbol] = {
api: apiRef,
deps: {},