diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx
index dafad5365c..338d293132 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx
@@ -210,6 +210,25 @@ describe('createApp', () => {
]
+ apis [
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ]
"
`);
});
diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json
index 9ca546721f..33b1f41a8c 100644
--- a/packages/frontend-test-utils/package.json
+++ b/packages/frontend-test-utils/package.json
@@ -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"
}
}
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
index bdcefb7b8b..de52ca52de 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
@@ -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',
}),
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.ts b/packages/frontend-test-utils/src/app/createExtensionTester.ts
deleted file mode 100644
index 18c3c84ec9..0000000000
--- a/packages/frontend-test-utils/src/app/createExtensionTester.ts
+++ /dev/null
@@ -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(
- subject: ExtensionDefinition,
- 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;
- config?: JsonValue;
- }>();
-
- add(
- extension: ExtensionDefinition,
- 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(
- subject: ExtensionDefinition,
- options?: { config?: TConfig },
-): ExtensionTester {
- return ExtensionTester.forSubject(subject, options);
-}
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx
new file mode 100644
index 0000000000..0404cf1ade
--- /dev/null
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx
@@ -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;
+ title: string;
+ icon: IconComponent;
+}) => {
+ const { routeRef, title, icon: Icon } = props;
+ const to = useRouteRef(routeRef)();
+ return (
+
+
+ {title}
+
+
+ );
+};
+
+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: (
+
+ ),
+ };
+ },
+});
+
+const AuthenticationProvider = (props: {
+ signInPage?: ComponentType;
+ 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();
+
+ 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 ;
+ }
+
+ 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: (
+
+
+ {children}
+
+
+ ),
+ };
+ },
+});
+
+/** @public */
+export class ExtensionTester {
+ /** @internal */
+ static forSubject(
+ subject: ExtensionDefinition,
+ options?: { config?: TConfig },
+ ): ExtensionTester {
+ const tester = new ExtensionTester();
+ const { output, factory, ...rest } =
+ subject as InternalExtensionDefinition;
+ // 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;
+ config?: JsonValue;
+ }>();
+
+ add(
+ extension: ExtensionDefinition,
+ 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(
+ subject: ExtensionDefinition,
+ options?: { config?: TConfig },
+): ExtensionTester {
+ return ExtensionTester.forSubject(subject, options);
+}
diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
index d51f66010c..a6783d43a3 100644
--- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
+++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx
@@ -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',
});
diff --git a/yarn.lock b/yarn.lock
index 8689a6ad0d..910432180d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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