Merge pull request #1201 from spotify/rugvip/nestapis
packages/core-api,test-utils
This commit is contained in:
@@ -51,6 +51,35 @@ describe('ApiProvider', () => {
|
||||
renderedHoc.getByText('hoc message: hello');
|
||||
});
|
||||
|
||||
it('should provide nested access to apis', () => {
|
||||
const aRef = createApiRef<string>({ id: 'a', description: '' });
|
||||
const bRef = createApiRef<string>({ id: 'b', description: '' });
|
||||
|
||||
const MyComponent = () => {
|
||||
const a = useApi(aRef);
|
||||
const b = useApi(bRef);
|
||||
return (
|
||||
<div>
|
||||
a={a} b={b}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderedHook = render(
|
||||
<ApiProvider
|
||||
apis={ApiRegistry.from([
|
||||
[aRef, 'x'],
|
||||
[bRef, 'y'],
|
||||
])}
|
||||
>
|
||||
<ApiProvider apis={ApiRegistry.from([[aRef, 'z']])}>
|
||||
<MyComponent />
|
||||
</ApiProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
renderedHook.getByText('a=z b=y');
|
||||
});
|
||||
|
||||
it('should ignore deps in prototype', () => {
|
||||
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
|
||||
const xRef = createApiRef<number>({ id: 'x', description: '' });
|
||||
|
||||
@@ -18,16 +18,20 @@ import React, { FC, createContext, useContext, ReactNode } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ApiRef } from './ApiRef';
|
||||
import { ApiHolder, TypesToApiRefs } from './types';
|
||||
import { ApiAggregator } from './ApiAggregator';
|
||||
|
||||
type Props = {
|
||||
type ApiProviderProps = {
|
||||
apis: ApiHolder;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
const Context = createContext<ApiHolder | undefined>(undefined);
|
||||
|
||||
export const ApiProvider: FC<Props> = ({ apis, children }) => {
|
||||
return <Context.Provider value={apis} children={children} />;
|
||||
export const ApiProvider: FC<ApiProviderProps> = ({ apis, children }) => {
|
||||
const parentHolder = useContext(Context);
|
||||
const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis;
|
||||
|
||||
return <Context.Provider value={holder} children={children} />;
|
||||
};
|
||||
|
||||
ApiProvider.propTypes = {
|
||||
|
||||
@@ -49,4 +49,20 @@ describe('ApiRegistry', () => {
|
||||
expect(registry.get(x1Ref)).toBe(3);
|
||||
expect(registry.get(x2Ref)).toBe('y');
|
||||
});
|
||||
|
||||
it('should be created with API', () => {
|
||||
const reg1 = ApiRegistry.with(x1Ref, 3);
|
||||
const reg2 = reg1.with(x2Ref, 'y');
|
||||
const reg3 = reg2.with(x2Ref, 'z');
|
||||
const reg4 = reg3.with(x1Ref, 2);
|
||||
|
||||
expect(reg1.get(x1Ref)).toBe(3);
|
||||
expect(reg1.get(x2Ref)).toBe(undefined);
|
||||
expect(reg2.get(x1Ref)).toBe(3);
|
||||
expect(reg2.get(x2Ref)).toBe('y');
|
||||
expect(reg3.get(x1Ref)).toBe(3);
|
||||
expect(reg3.get(x2Ref)).toBe('z');
|
||||
expect(reg4.get(x1Ref)).toBe(2);
|
||||
expect(reg4.get(x2Ref)).toBe('z');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,8 +42,28 @@ export class ApiRegistry implements ApiHolder {
|
||||
return new ApiRegistry(new Map(apis));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ApiRegistry with a single API implementation.
|
||||
*
|
||||
* @param api ApiRef for the API to add
|
||||
* @param impl Implementation of the API to add
|
||||
*/
|
||||
static with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
|
||||
return new ApiRegistry(new Map([[api, impl]]));
|
||||
}
|
||||
|
||||
constructor(private readonly apis: Map<ApiRef<unknown>, unknown>) {}
|
||||
|
||||
/**
|
||||
* Returns a new ApiRegistry with the provided API added to the existing ones.
|
||||
*
|
||||
* @param api ApiRef for the API to add
|
||||
* @param impl Implementation of the API to add
|
||||
*/
|
||||
with<T>(api: ApiRef<T>, impl: T): ApiRegistry {
|
||||
return new ApiRegistry(new Map([...this.apis, [api, impl]]));
|
||||
}
|
||||
|
||||
get<T>(api: ApiRef<T>): T | undefined {
|
||||
return this.apis.get(api) as T | undefined;
|
||||
}
|
||||
|
||||
@@ -14,22 +14,59 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp } from './appWrappers';
|
||||
import { wrapInTestApp, renderInTestApp } from './appWrappers';
|
||||
import { Route } from 'react-router';
|
||||
import { withLogCollector } from '@backstage/test-utils-core';
|
||||
|
||||
describe('wrapInTestApp', () => {
|
||||
it('should provide routing', () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<>
|
||||
<Route path="/route1">Route 1</Route>
|
||||
<Route path="/route2">Route 2</Route>
|
||||
</>,
|
||||
{ routeEntries: ['/route2'] },
|
||||
it('should provide routing and warn about missing act()', async () => {
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<>
|
||||
<Route path="/route1">Route 1</Route>
|
||||
<Route path="/route2">Route 2</Route>
|
||||
</>,
|
||||
{ routeEntries: ['/route2'] },
|
||||
),
|
||||
);
|
||||
expect(rendered.getByText('Route 2')).toBeInTheDocument();
|
||||
|
||||
// Wait for async actions to trigger the act() warnings that we assert below
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(error).toEqual([
|
||||
expect.stringMatching(
|
||||
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
|
||||
),
|
||||
);
|
||||
expect(rendered.getByText('Route 2')).toBeInTheDocument();
|
||||
expect.stringMatching(
|
||||
/^Warning: An update to %s inside a test was not wrapped in act\(...\)/,
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render a component in a test app without warning about missing act()', async () => {
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
const Foo: FC<{}> = () => {
|
||||
return <p>foo</p>;
|
||||
};
|
||||
|
||||
const rendered = await renderInTestApp(Foo);
|
||||
expect(rendered.getByText('foo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(error).toEqual([]);
|
||||
});
|
||||
|
||||
it('should render a node in a test app', async () => {
|
||||
const Foo: FC<{}> = () => {
|
||||
return <p>foo</p>;
|
||||
};
|
||||
|
||||
const rendered = await renderInTestApp(<Foo />);
|
||||
expect(rendered.getByText('foo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType, ReactNode, FunctionComponent, FC } from 'react';
|
||||
import React, { ComponentType, ReactNode, FC, ReactElement } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { Route } from 'react-router-dom';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
@@ -23,6 +23,8 @@ import privateExports, {
|
||||
ApiTestRegistry,
|
||||
BootErrorPageProps,
|
||||
} from '@backstage/core-api';
|
||||
import { RenderResult } from '@testing-library/react';
|
||||
import { renderWithEffects } from '@backstage/test-utils-core';
|
||||
const { PrivateAppImpl } = privateExports;
|
||||
|
||||
const NotFoundErrorPage = () => {
|
||||
@@ -43,10 +45,17 @@ type TestAppOptions = {
|
||||
routeEntries?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a component inside a Backstage test app, providing a mocked theme
|
||||
* and app context, along with mocked APIs.
|
||||
*
|
||||
* @param Component - A component or react node to render inside the test app.
|
||||
* @param options - Additional options for the rendering.
|
||||
*/
|
||||
export function wrapInTestApp(
|
||||
Component: ComponentType | ReactNode,
|
||||
options: TestAppOptions = {},
|
||||
) {
|
||||
): ReactElement {
|
||||
const { routeEntries = ['/'] } = options;
|
||||
|
||||
const app = new PrivateAppImpl({
|
||||
@@ -75,7 +84,7 @@ export function wrapInTestApp(
|
||||
if (Component instanceof Function) {
|
||||
Wrapper = Component;
|
||||
} else {
|
||||
Wrapper = (() => Component) as FunctionComponent;
|
||||
Wrapper = (() => Component) as FC;
|
||||
}
|
||||
|
||||
const AppProvider = app.getProvider();
|
||||
@@ -86,3 +95,20 @@ export function wrapInTestApp(
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a component inside a Backstage test app, providing a mocked theme
|
||||
* and app context, along with mocked APIs.
|
||||
*
|
||||
* The render executes async effects similar to `renderWithEffects`. To avoid this
|
||||
* behavior, use a regular `render()` + `wrapInTestApp()` instead.
|
||||
*
|
||||
* @param Component - A component or react node to render inside the test app.
|
||||
* @param options - Additional options for the rendering.
|
||||
*/
|
||||
export async function renderInTestApp(
|
||||
Component: ComponentType | ReactNode,
|
||||
options: TestAppOptions = {},
|
||||
): Promise<RenderResult> {
|
||||
return renderWithEffects(wrapInTestApp(Component, options));
|
||||
}
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
*/
|
||||
|
||||
export { default as mockBreakpoint } from './mockBreakpoint';
|
||||
export * from './appWrappers';
|
||||
export { wrapInTestApp, renderInTestApp } from './appWrappers';
|
||||
|
||||
Reference in New Issue
Block a user