refactor(frontend-test-utils): simplying core nav and router for test apps

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2023-12-13 16:43:24 +01:00
parent 66ce9f6d1e
commit 67409cbfe5
7 changed files with 327 additions and 143 deletions
@@ -210,6 +210,25 @@ describe('createApp', () => {
<theme:app/light out=[core.theme.theme] />
<theme:app/dark out=[core.theme.theme] />
]
apis [
<api:core.discovery out=[core.api.factory] />
<api:core.alert out=[core.api.factory] />
<api:core.analytics out=[core.api.factory] />
<api:core.error out=[core.api.factory] />
<api:core.storage out=[core.api.factory] />
<api:core.fetch out=[core.api.factory] />
<api:core.oauthrequest out=[core.api.factory] />
<api:core.auth.google out=[core.api.factory] />
<api:core.auth.microsoft out=[core.api.factory] />
<api:core.auth.github out=[core.api.factory] />
<api:core.auth.okta out=[core.api.factory] />
<api:core.auth.gitlab out=[core.api.factory] />
<api:core.auth.onelogin out=[core.api.factory] />
<api:core.auth.bitbucket out=[core.api.factory] />
<api:core.auth.bitbucket-server out=[core.api.factory] />
<api:core.auth.atlassian out=[core.api.factory] />
<api:plugin.permission.api out=[core.api.factory] />
]
</core>"
`);
});
+2 -1
View File
@@ -39,6 +39,7 @@
},
"peerDependencies": {
"@testing-library/react": "^12.1.3 || ^13.0.0 || ^14.0.0",
"react": "^16.13.1 || ^17.0.0 || ^18.0.0"
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "6.0.0-beta.0 || ^6.3.0"
}
}
@@ -103,7 +103,7 @@ describe('createExtensionTester', () => {
).resolves.toBeInTheDocument();
});
it.skip('should accepts a custom config', async () => {
it('should accepts a custom config', async () => {
const indexPageExtension = createExtension({
...defaultDefinition,
configSchema: createSchemaFromZod(z =>
@@ -175,7 +175,7 @@ describe('createExtensionTester', () => {
).resolves.toBeInTheDocument();
});
it.skip('should capture click events in anylitics', async () => {
it('should capture click events in anylitics', async () => {
// Mocking the analytics api implementation
const analyticsApiMock = new MockAnalyticsApi();
@@ -219,7 +219,7 @@ describe('createExtensionTester', () => {
fireEvent.click(await screen.findByRole('button', { name: 'See details' }));
await waitFor(() =>
expect(analyticsApiMock.getEvents()[1]).toMatchObject({
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'See details',
}),
@@ -1,136 +0,0 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createSpecializedApp } from '@backstage/frontend-app-api';
import {
ExtensionDefinition,
coreExtensionData,
createExtension,
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
import { MockConfigApi } from '@backstage/test-utils';
import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
import { RenderResult, render } from '@testing-library/react';
/** @public */
export class ExtensionTester {
/** @internal */
static forSubject<TConfig>(
subject: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
const tester = new ExtensionTester();
const { output, factory, ...rest } = subject;
// attaching to core/routes to render as index route
const extension = createExtension({
...rest,
attachTo: { id: 'core/routes', input: 'routes' },
output: {
...output,
path: coreExtensionData.routePath,
},
factory: params => ({
...factory(params),
path: '/',
}),
});
tester.add(extension, options);
return tester;
}
readonly #extensions = new Array<{
id: string;
definition: ExtensionDefinition<any>;
config?: JsonValue;
}>();
add<TConfig>(
extension: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
const { name, namespace } = extension;
const definition = {
...extension,
// setting name "test" as fallback
name: !namespace && !name ? 'test' : name,
};
const { id } = resolveExtensionDefinition(definition);
this.#extensions.push({
id,
definition,
config: options?.config as JsonValue,
});
return this;
}
render(options?: { config?: JsonObject }): RenderResult {
const { config = {} } = options ?? {};
const [subject, ...rest] = this.#extensions;
if (!subject) {
throw new Error(
'No subject found. At least one extension should be added to the tester.',
);
}
const extensionsConfig: JsonArray = [
...rest.map(extension => ({
[extension.id]: {
config: extension.config,
},
})),
{
[subject.id]: {
config: subject.config,
disabled: false,
},
},
];
const finalConfig = {
...config,
app: {
...(typeof config.app === 'object' ? config.app : undefined),
extensions: extensionsConfig,
},
};
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: this.#extensions.map(extension => extension.definition),
}),
],
config: new MockConfigApi(finalConfig),
});
return render(app.createRoot());
}
}
/** @public */
export function createExtensionTester<TConfig>(
subject: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
return ExtensionTester.forSubject(subject, options);
}
@@ -0,0 +1,301 @@
/*
* 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 React, { ComponentType, ReactNode, useContext, useState } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { Link } from '@backstage/core-components';
import { RenderResult, render } from '@testing-library/react';
import { createSpecializedApp } from '@backstage/frontend-app-api';
import {
ExtensionDefinition,
IconComponent,
IdentityApi,
RouteRef,
configApiRef,
coreExtensionData,
createExtension,
createExtensionInput,
createExtensionOverrides,
createNavItemExtension,
useApi,
useRouteRef,
} from '@backstage/frontend-plugin-api';
import { MockConfigApi } from '@backstage/test-utils';
import { JsonArray, JsonObject, JsonValue } from '@backstage/types';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { resolveExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/resolveExtensionDefinition';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { createSignInPageExtension } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { InternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { InternalAppContext } from '../../../frontend-app-api/src/wiring/InternalAppContext';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { SignInPageProps } from '../../../core-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { getBasePath } from '../../../core-app-api/src/app/AppRouter';
const NavItem = (props: {
routeRef: RouteRef<undefined>;
title: string;
icon: IconComponent;
}) => {
const { routeRef, title, icon: Icon } = props;
const to = useRouteRef(routeRef)();
return (
<li>
<Link to={to}>
<Icon /> {title}
</Link>
</li>
);
};
const TestCoreNavExtension = createExtension({
namespace: 'core',
name: 'nav',
attachTo: { id: 'core/layout', input: 'nav' },
inputs: {
items: createExtensionInput({
target: createNavItemExtension.targetDataRef,
}),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs }) {
return {
element: (
<nav>
<ul>
{inputs.items.map((item, index) => (
<NavItem
key={index}
icon={item.output.target.icon}
title={item.output.target.title}
routeRef={item.output.target.routeRef}
/>
))}
</ul>
</nav>
),
};
},
});
const AuthenticationProvider = (props: {
signInPage?: ComponentType<SignInPageProps>;
children: ReactNode;
}) => {
const { signInPage: SignInPage, children } = props;
const configApi = useApi(configApiRef);
const signOutTargetUrl = getBasePath(configApi) || '/';
const internalAppContext = useContext(InternalAppContext);
if (!internalAppContext) {
throw new Error('AppRouter must be rendered within the AppProvider');
}
const { appIdentityProxy } = internalAppContext;
const [identityApi, setIdentityApi] = useState<IdentityApi>();
if (!SignInPage) {
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl },
);
return children;
}
if (!identityApi) {
return <SignInPage onSignInSuccess={setIdentityApi} />;
}
appIdentityProxy.setTarget(identityApi, {
signOutTargetUrl,
});
return children;
};
const TestCoreRouterExtension = createExtension({
namespace: 'core',
name: 'router',
attachTo: { id: 'core', input: 'root' },
inputs: {
signInPage: createExtensionInput(
{
component: createSignInPageExtension.componentDataRef,
},
{ singleton: true, optional: true },
),
children: createExtensionInput(
{
element: coreExtensionData.reactElement,
},
{ singleton: true },
),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ inputs }) {
const SignInPage = inputs.signInPage?.output.component;
const children = inputs.children.output.element;
return {
element: (
<MemoryRouter>
<AuthenticationProvider signInPage={SignInPage}>
{children}
</AuthenticationProvider>
</MemoryRouter>
),
};
},
});
/** @public */
export class ExtensionTester {
/** @internal */
static forSubject<TConfig>(
subject: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
const tester = new ExtensionTester();
const { output, factory, ...rest } =
subject as InternalExtensionDefinition<TConfig>;
// attaching to core/routes to render as index route
const extension = createExtension({
...rest,
attachTo: { id: 'core/routes', input: 'routes' },
output: {
...output,
path: coreExtensionData.routePath,
},
factory: params => ({
...factory(params),
path: '/',
}),
});
tester.add(extension, options);
return tester;
}
readonly #extensions = new Array<{
id: string;
definition: ExtensionDefinition<any>;
config?: JsonValue;
}>();
add<TConfig>(
extension: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
const { name, namespace } = extension;
const definition = {
...extension,
// setting name "test" as fallback
name: !namespace && !name ? 'test' : name,
};
const { id } = resolveExtensionDefinition(definition);
this.#extensions.push({
id,
definition,
config: options?.config as JsonValue,
});
return this;
}
render(options?: { config?: JsonObject }): RenderResult {
const { config = {} } = options ?? {};
const [subject, ...rest] = this.#extensions;
if (!subject) {
throw new Error(
'No subject found. At least one extension should be added to the tester.',
);
}
const extensionsConfig: JsonArray = [
...rest.map(extension => ({
[extension.id]: {
config: extension.config,
},
})),
{
[subject.id]: {
config: subject.config,
disabled: false,
},
},
];
const finalConfig = {
...config,
app: {
...(typeof config.app === 'object' ? config.app : undefined),
extensions: extensionsConfig,
},
};
const app = createSpecializedApp({
features: [
createExtensionOverrides({
extensions: [
...this.#extensions.map(extension => extension.definition),
TestCoreNavExtension,
TestCoreRouterExtension,
],
}),
],
config: new MockConfigApi(finalConfig),
});
return render(app.createRoot());
}
}
/** @public */
export function createExtensionTester<TConfig>(
subject: ExtensionDefinition<TConfig>,
options?: { config?: TConfig },
): ExtensionTester {
return ExtensionTester.forSubject(subject, options);
}
@@ -56,9 +56,7 @@ describe('renderInTestApp', () => {
fireEvent.click(screen.getByRole('link', { name: 'See details' }));
const events = analyticsApiMock.getEvents();
expect(events[1]).toMatchObject({
expect(analyticsApiMock.getEvents()[0]).toMatchObject({
action: 'click',
subject: 'See details',
});
+1
View File
@@ -4237,6 +4237,7 @@ __metadata:
peerDependencies:
"@testing-library/react": ^12.1.3 || ^13.0.0 || ^14.0.0
react: ^16.13.1 || ^17.0.0 || ^18.0.0
react-router-dom: 6.0.0-beta.0 || ^6.3.0
languageName: unknown
linkType: soft