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:
@@ -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>"
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user