From 3fcb1313ff60b682ca5c44909a3e102f96681581 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 24 Jul 2024 17:09:29 +0200 Subject: [PATCH] chore: implementing some other apis for testing extensions without a react tree Signed-off-by: blam --- packages/frontend-test-utils/package.json | 1 + .../src/app/createExtensionTester.test.tsx | 135 ++++++++++++++ .../src/app/createExtensionTester.tsx | 167 +++++++++++++++--- yarn.lock | 1 + 4 files changed, 277 insertions(+), 27 deletions(-) diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index 1d0b00132b..89a3eb10db 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -31,6 +31,7 @@ "test": "backstage-cli package test" }, "dependencies": { + "@backstage/config": "workspace:^", "@backstage/frontend-app-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/test-utils": "workspace:^", diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index f59d44f6d7..889c815fc7 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx @@ -24,6 +24,8 @@ import { createApiExtension, createApiFactory, createExtension, + createExtensionDataRef, + createExtensionInput, createSchemaFromZod, useAnalytics, useApi, @@ -31,6 +33,10 @@ import { import { MockAnalyticsApi } from '../apis'; import { createExtensionTester } from './createExtensionTester'; +const stringDataRef = createExtensionDataRef().with({ + id: 'test.string', +}); + describe('createExtensionTester', () => { const defaultDefinition = { namespace: 'test', @@ -229,4 +235,133 @@ describe('createExtensionTester', () => { ), ); }); + + it('should return the correct dataRef when called', () => { + const extension = createExtension({ + namespace: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { text: stringDataRef }, + factory: () => ({ text: 'test-text' }), + }); + + const tester = createExtensionTester(extension); + + expect(tester.data(stringDataRef)).toBe('test-text'); + }); + + it('should throw an error if trying to access an instance not provided to the tester', () => { + const extension = createExtension({ + namespace: 'test', + name: 'e1', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { text: stringDataRef }, + factory: () => ({ text: 'test-text' }), + }); + + const extension2 = createExtension({ + namespace: 'test', + name: 'e2', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { text: stringDataRef }, + factory: () => ({ text: 'test-text' }), + }); + + const tester = createExtensionTester(extension); + + expect(() => tester.query(extension2)).toThrow( + "Extension with ID 'test/e2' not found, please make sure it's added to the tester", + ); + }); + + it('should throw an error if trying to access an instance which is not part of the tree', () => { + const extension = createExtension({ + namespace: 'test', + name: 'e1', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { text: stringDataRef }, + factory: () => ({ text: 'test-text' }), + }); + + const extension2 = createExtension({ + namespace: 'test', + name: 'e2', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { text: stringDataRef }, + factory: () => ({ text: 'test-text' }), + }); + + const tester = createExtensionTester(extension).add(extension2); + + expect(() => tester.query(extension2)).toThrow( + "Extension with ID 'test/e2' has not been instantiated, because it is not part of the test subject's extension tree", + ); + }); + + // TODO: this should be implemented + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should allow querying an extension and getting outputs', () => { + const extension = createExtension({ + namespace: 'test', + name: 'e1', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { text: stringDataRef }, + inputs: { + input: createExtensionInput( + { + output: stringDataRef, + }, + { singleton: true }, + ), + }, + factory: ({ inputs }) => ({ + text: `nest-${inputs.input.output.output}`, + }), + }); + + const extension2 = createExtension({ + namespace: 'test', + name: 'e2', + attachTo: { id: 'test/e1', input: 'blob' }, + output: { text: stringDataRef }, + factory: () => ({ text: 'test-text' }), + }); + + const tester = createExtensionTester(extension).add(extension2); + + expect(tester.query(extension).data(stringDataRef)).toBe('nest-test-text'); + expect(tester.query(extension2).data(stringDataRef)).toBe('test-text'); + // @ts-expect-error + expect(tester.query(extension).input('input').data(stringDataRef)).toBe( + 'nest-test-text', + ); + }); + + // TODO: this should be implemented + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should allow defining inputs to extensions without a corresponding extension definition', () => { + const extension = createExtension({ + namespace: 'test', + name: 'e1', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { text: stringDataRef }, + inputs: { + input: createExtensionInput( + { + output: stringDataRef, + }, + { singleton: true }, + ), + }, + factory: ({ inputs }) => ({ + text: `nest-${inputs.input.output.output}`, + }), + }); + + const tester = createExtensionTester(extension, { + // @ts-expect-error + inputs: { input: 'test-text' }, + }); + + expect(tester.query(extension).data(stringDataRef)).toBe('nest-test-text'); + }); }); diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 81b74c9fce..1d41958a09 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -20,6 +20,10 @@ import { RenderResult, render } from '@testing-library/react'; import { createSpecializedApp } from '@backstage/frontend-app-api'; import { ExtensionDataValue, + AppNode, + AppTree, + Extension, + ExtensionDataRef, ExtensionDefinition, IconComponent, RouteRef, @@ -31,12 +35,20 @@ import { createRouterExtension, useRouteRef, } from '@backstage/frontend-plugin-api'; -import { MockConfigApi } from '@backstage/test-utils'; +import { Config, ConfigReader } from '@backstage/config'; 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 { toInternalExtensionDefinition } from '../../../frontend-plugin-api/src/wiring/createExtension'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { resolveAppTree } from '../../../frontend-app-api/src/tree/resolveAppTree'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { resolveAppNodeSpecs } from '../../../frontend-app-api/src/tree/resolveAppNodeSpecs'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { instantiateAppNodeTree } from '../../../frontend-app-api/src/tree/instantiateAppNodeTree'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/readAppExtensionsConfig'; const NavItem = (props: { routeRef: RouteRef; @@ -86,6 +98,35 @@ const TestAppNavExtension = createExtension({ }, }); +/** @public */ +export class ExtensionQuery { + #node: AppNode; + + constructor(node: AppNode) { + this.#node = node; + } + + get node() { + return this.#node; + } + + get instance() { + const instance = this.#node.instance; + if (!instance) { + throw new Error( + `Unable to access the instance of extension with ID '${ + this.#node.spec.id + }'`, + ); + } + return instance; + } + + data(ref: ExtensionDataRef): T | undefined { + return this.instance.getData(ref); + } +} + /** @public */ export class ExtensionTester { /** @internal */ @@ -139,8 +180,11 @@ export class ExtensionTester { return tester; } + #tree?: AppTree; + readonly #extensions = new Array<{ id: string; + extension: Extension; definition: ExtensionDefinition; config?: JsonValue; }>(); @@ -149,6 +193,12 @@ export class ExtensionTester { extension: ExtensionDefinition, options?: { config?: TConfigInput }, ): ExtensionTester { + if (this.#tree) { + throw new Error( + 'Cannot add more extensions accessing the extension tree', + ); + } + const { name, namespace } = extension; const definition = { @@ -157,10 +207,11 @@ export class ExtensionTester { name: !namespace && !name ? 'test' : name, }; - const { id } = resolveExtensionDefinition(definition); + const resolvedExtension = resolveExtensionDefinition(definition); this.#extensions.push({ - id, + id: resolvedExtension.id, + extension: resolvedExtension, definition, config: options?.config as JsonValue, }); @@ -168,38 +219,42 @@ export class ExtensionTester { return this; } + data(ref: ExtensionDataRef): T | undefined { + const tree = this.#resolveTree(); + + return new ExtensionQuery(tree.root).data(ref); + } + + query(id: string | ExtensionDefinition): ExtensionQuery { + const tree = this.#resolveTree(); + + const actualId = + typeof id === 'string' ? id : resolveExtensionDefinition(id).id; + + const node = tree.nodes.get(actualId); + + if (!node) { + throw new Error( + `Extension with ID '${actualId}' not found, please make sure it's added to the tester.`, + ); + } else if (!node.instance) { + throw new Error( + `Extension with ID '${actualId}' has not been instantiated, because it is not part of the test subject's extension tree.`, + ); + } + return new ExtensionQuery(node); + } + render(options?: { config?: JsonObject }): RenderResult { const { config = {} } = options ?? {}; - const [subject, ...rest] = this.#extensions; + const [subject] = 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({ @@ -215,11 +270,69 @@ export class ExtensionTester { ], }), ], - config: new MockConfigApi(finalConfig), + config: this.#getConfig(config), }); return render(app.createRoot()); } + + #resolveTree() { + if (this.#tree) { + return this.#tree; + } + + const [subject] = this.#extensions; + if (!subject) { + throw new Error( + 'No subject found. At least one extension should be added to the tester.', + ); + } + + const tree = resolveAppTree( + subject.id, + resolveAppNodeSpecs({ + features: [], + builtinExtensions: this.#extensions.map(_ => _.extension), + parameters: readAppExtensionsConfig(this.#getConfig()), + }), + ); + + instantiateAppNodeTree(tree.root); + + this.#tree = tree; + + return tree; + } + + #getConfig(additionalConfig?: JsonObject): Config { + const [subject, ...rest] = this.#extensions; + + const extensionsConfig: JsonArray = [ + ...rest.map(extension => ({ + [extension.id]: { + config: extension.config, + }, + })), + { + [subject.id]: { + config: subject.config, + disabled: false, + }, + }, + ]; + + return ConfigReader.fromConfigs([ + { context: 'render-config', data: additionalConfig ?? {} }, + { + context: 'test', + data: { + app: { + extensions: extensionsConfig, + }, + }, + }, + ]); + } } /** @public */ diff --git a/yarn.lock b/yarn.lock index 155c0eeb9e..135cd07035 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4546,6 +4546,7 @@ __metadata: resolution: "@backstage/frontend-test-utils@workspace:packages/frontend-test-utils" dependencies: "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^"