chore: implementing some other apis for testing extensions without a react tree

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2024-07-24 17:09:29 +02:00
parent 7ac4599eeb
commit 3fcb1313ff
4 changed files with 277 additions and 27 deletions
@@ -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:^",
@@ -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<string>().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');
});
});
@@ -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<undefined>;
@@ -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<T>(ref: ExtensionDataRef<T>): 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<any>;
definition: ExtensionDefinition<any>;
config?: JsonValue;
}>();
@@ -149,6 +193,12 @@ export class ExtensionTester {
extension: ExtensionDefinition<TConfig, TConfigInput>,
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<T>(ref: ExtensionDataRef<T>): T | undefined {
const tree = this.#resolveTree();
return new ExtensionQuery(tree.root).data(ref);
}
query(id: string | ExtensionDefinition<any, any>): 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 */
+1
View File
@@ -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:^"