diff --git a/packages/core-api/src/apis/ApiProvider.test.tsx b/packages/core-api/src/apis/ApiProvider.test.tsx index 255b35a0d2..ac64aca3a7 100644 --- a/packages/core-api/src/apis/ApiProvider.test.tsx +++ b/packages/core-api/src/apis/ApiProvider.test.tsx @@ -51,6 +51,35 @@ describe('ApiProvider', () => { renderedHoc.getByText('hoc message: hello'); }); + it('should provide nested access to apis', () => { + const aRef = createApiRef({ id: 'a', description: '' }); + const bRef = createApiRef({ id: 'b', description: '' }); + + const MyComponent = () => { + const a = useApi(aRef); + const b = useApi(bRef); + return ( +
+ a={a} b={b} +
+ ); + }; + + const renderedHook = render( + + + + + , + ); + renderedHook.getByText('a=z b=y'); + }); + it('should ignore deps in prototype', () => { // 100% coverage + happy typescript = hasOwnProperty + this atrocity const xRef = createApiRef({ id: 'x', description: '' }); diff --git a/packages/core-api/src/apis/ApiProvider.tsx b/packages/core-api/src/apis/ApiProvider.tsx index 1c5abf1c56..e157782024 100644 --- a/packages/core-api/src/apis/ApiProvider.tsx +++ b/packages/core-api/src/apis/ApiProvider.tsx @@ -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(undefined); -export const ApiProvider: FC = ({ apis, children }) => { - return ; +export const ApiProvider: FC = ({ apis, children }) => { + const parentHolder = useContext(Context); + const holder = parentHolder ? new ApiAggregator(apis, parentHolder) : apis; + + return ; }; ApiProvider.propTypes = { diff --git a/packages/core-api/src/apis/ApiRegistry.test.ts b/packages/core-api/src/apis/ApiRegistry.test.ts index 2971397e7a..a859d51004 100644 --- a/packages/core-api/src/apis/ApiRegistry.test.ts +++ b/packages/core-api/src/apis/ApiRegistry.test.ts @@ -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'); + }); }); diff --git a/packages/core-api/src/apis/ApiRegistry.ts b/packages/core-api/src/apis/ApiRegistry.ts index 0a9d14a4e7..64d97ba234 100644 --- a/packages/core-api/src/apis/ApiRegistry.ts +++ b/packages/core-api/src/apis/ApiRegistry.ts @@ -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(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([[api, impl]])); + } + constructor(private readonly apis: Map, 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(api: ApiRef, impl: T): ApiRegistry { + return new ApiRegistry(new Map([...this.apis, [api, impl]])); + } + get(api: ApiRef): T | undefined { return this.apis.get(api) as T | undefined; } diff --git a/packages/test-utils/src/testUtils/appWrappers.test.tsx b/packages/test-utils/src/testUtils/appWrappers.test.tsx index e46b6a95ce..a30e8f9823 100644 --- a/packages/test-utils/src/testUtils/appWrappers.test.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.test.tsx @@ -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 1 - Route 2 - , - { routeEntries: ['/route2'] }, + it('should provide routing and warn about missing act()', async () => { + const { error } = await withLogCollector(['error'], async () => { + const rendered = render( + wrapInTestApp( + <> + Route 1 + Route 2 + , + { 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

foo

; + }; + + 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

foo

; + }; + + const rendered = await renderInTestApp(); + expect(rendered.getByText('foo')).toBeInTheDocument(); }); }); diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 6fa6f62d8c..f191f955a3 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -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( ); } + +/** + * 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 { + return renderWithEffects(wrapInTestApp(Component, options)); +} diff --git a/packages/test-utils/src/testUtils/index.tsx b/packages/test-utils/src/testUtils/index.tsx index ea33de2f31..302bdb405f 100644 --- a/packages/test-utils/src/testUtils/index.tsx +++ b/packages/test-utils/src/testUtils/index.tsx @@ -15,4 +15,4 @@ */ export { default as mockBreakpoint } from './mockBreakpoint'; -export * from './appWrappers'; +export { wrapInTestApp, renderInTestApp } from './appWrappers';