chore: implementing some other apis for testing extensions without a react tree
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user