frontend-app-api: add support for plugin info resolution and overrides
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Implemented support for the `plugin.info()` method in specialized apps with a default resolved for `package.json` and `catalog-info.yaml`. The default resolution logic can be overriden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration.
|
||||
+66
@@ -51,5 +51,71 @@ export interface Config {
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* This section enables you to override certain properties of specific or
|
||||
* groups of plugins.
|
||||
*
|
||||
* @remarks
|
||||
* All matching entries will be applied to each plugin, with the later
|
||||
* entries taking precedence.
|
||||
*
|
||||
* This configuration is intended to be used primarily to apply overrides
|
||||
* for third-party plugins.
|
||||
*
|
||||
* @deepVisibility frontend
|
||||
*/
|
||||
pluginOverrides?: Array<{
|
||||
/**
|
||||
* The criteria for matching plugins to override.
|
||||
*
|
||||
* @remarks
|
||||
* If no match criteria are provided, the override will be applied to
|
||||
* all plugins.
|
||||
*/
|
||||
match?: {
|
||||
/**
|
||||
* A pattern that is matched against the plugin ID.
|
||||
*
|
||||
* @remarks
|
||||
* By default the string is interpreted as a glob pattern, but if the
|
||||
* string is surrounded by '/' it is interpreted as a regex.
|
||||
*/
|
||||
pluginId?: string;
|
||||
|
||||
/**
|
||||
* A pattern that is matched against the package name.
|
||||
*
|
||||
* @remarks
|
||||
* By default the string is interpreted as a glob pattern, but if the
|
||||
* string is surrounded by '/' it is interpreted as a regex.
|
||||
*
|
||||
* Note that this will only work for plugins that provide a
|
||||
* `package.json` info loader.
|
||||
*/
|
||||
packageName?: string;
|
||||
};
|
||||
/**
|
||||
* Overrides individual top-level fields of the plugin info.
|
||||
*/
|
||||
info: {
|
||||
/**
|
||||
* Override the description of the plugin.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* Override the owner entity references of the plugin.
|
||||
*
|
||||
* @remarks
|
||||
* The provided values are interpreted as entity references defaulting
|
||||
* to Group entities in the default namespace.
|
||||
*/
|
||||
ownerEntityRefs?: string[];
|
||||
/**
|
||||
* Override the links of the plugin.
|
||||
*/
|
||||
links?: Array<{ title: string; url: string }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
|
||||
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api';
|
||||
import { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { RouteRef } from '@backstage/frontend-plugin-api';
|
||||
import { SubRouteRef } from '@backstage/frontend-plugin-api';
|
||||
|
||||
@@ -27,7 +29,7 @@ export type CreateAppRouteBinder = <
|
||||
|
||||
// @public
|
||||
export function createSpecializedApp(options?: {
|
||||
features?: FrontendFeature[];
|
||||
features?: FrontendFeature_2[];
|
||||
config?: ConfigApi;
|
||||
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
|
||||
apis?: ApiHolder;
|
||||
@@ -37,6 +39,7 @@ export function createSpecializedApp(options?: {
|
||||
flags?: {
|
||||
allowUnknownExtensionConfig?: boolean;
|
||||
};
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
}): {
|
||||
apis: ApiHolder;
|
||||
tree: AppTree;
|
||||
@@ -44,4 +47,18 @@ export function createSpecializedApp(options?: {
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type FrontendFeature = FrontendFeature_2;
|
||||
|
||||
// @public
|
||||
export type FrontendPluginInfoResolver = (ctx: {
|
||||
packageJson(): Promise<JsonObject | undefined>;
|
||||
manifest(): Promise<JsonObject | undefined>;
|
||||
defaultResolver(sources: {
|
||||
packageJson: JsonObject | undefined;
|
||||
manifest: JsonObject | undefined;
|
||||
}): Promise<{
|
||||
info: FrontendPluginInfo;
|
||||
}>;
|
||||
}) => Promise<{
|
||||
info: FrontendPluginInfo;
|
||||
}>;
|
||||
```
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
* Copyright 2025 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 { mockApis } from '@backstage/test-utils';
|
||||
import { createPluginInfoAttacher } from './createPluginInfoAttacher';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
import {
|
||||
createFrontendPlugin,
|
||||
FrontendFeature,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
|
||||
function getInfo(plugin: FrontendFeature) {
|
||||
return OpaqueFrontendPlugin.toInternal(plugin).info();
|
||||
}
|
||||
|
||||
describe('createPluginInfoAttacher', () => {
|
||||
const mockConfig = mockApis.config({
|
||||
data: {
|
||||
app: {
|
||||
pluginOverrides: [
|
||||
{
|
||||
match: {
|
||||
pluginId: '/^.*-tester$/',
|
||||
},
|
||||
info: {
|
||||
description: 'Overridden description',
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {
|
||||
pluginId: '/^not-.*-tester$/',
|
||||
},
|
||||
info: {
|
||||
ownerEntityRefs: ['test-group'],
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {
|
||||
packageName: '@test/package',
|
||||
},
|
||||
info: {
|
||||
description: 'Package name matched',
|
||||
},
|
||||
},
|
||||
{
|
||||
match: {
|
||||
pluginId: 'info-tester',
|
||||
},
|
||||
info: {
|
||||
links: [{ title: 'Custom Link', url: 'https://example.com' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe('with default resolver', () => {
|
||||
const attacher = createPluginInfoAttacher(mockConfig);
|
||||
|
||||
it('should return a new plugin instance', async () => {
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
});
|
||||
|
||||
const newPlugin = attacher(plugin);
|
||||
expect(newPlugin).not.toBe(plugin);
|
||||
await expect(getInfo(newPlugin)).resolves.toEqual({});
|
||||
});
|
||||
|
||||
it('should return non-plugin features unchanged', () => {
|
||||
const nonPluginFeature = {
|
||||
type: 'not-a-plugin',
|
||||
} as unknown as FrontendFeature;
|
||||
|
||||
expect(attacher(nonPluginFeature)).toBe(nonPluginFeature);
|
||||
});
|
||||
|
||||
it('should resolve plugin info from package.json and config overrides', async () => {
|
||||
await expect(
|
||||
getInfo(
|
||||
attacher(
|
||||
createFrontendPlugin({
|
||||
pluginId: 'other-tester',
|
||||
info: {
|
||||
packageJson: async () => ({
|
||||
name: '@test/package',
|
||||
version: '1.0.0',
|
||||
description: 'Original description',
|
||||
homepage: 'https://homepage.com',
|
||||
repository: {
|
||||
url: 'https://github.com/test/project',
|
||||
directory: 'packages/test',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
packageName: '@test/package',
|
||||
version: '1.0.0',
|
||||
description: 'Package name matched',
|
||||
links: [
|
||||
{
|
||||
title: 'Homepage',
|
||||
url: 'https://homepage.com',
|
||||
},
|
||||
{
|
||||
title: 'Repository',
|
||||
url: 'https://github.com/test/project/tree/master/packages/test',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getInfo(
|
||||
attacher(
|
||||
createFrontendPlugin({
|
||||
pluginId: 'info-tester',
|
||||
info: {
|
||||
packageJson: async () => ({
|
||||
name: '@other/package',
|
||||
description: 'Original description',
|
||||
homepage: 'https://homepage.com',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
packageName: '@other/package',
|
||||
description: 'Overridden description',
|
||||
links: [
|
||||
{
|
||||
title: 'Custom Link',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getInfo(
|
||||
attacher(
|
||||
createFrontendPlugin({
|
||||
pluginId: 'not-info-tester',
|
||||
info: {
|
||||
packageJson: async () => ({
|
||||
name: '@other/package',
|
||||
description: 'Original description',
|
||||
repository: {
|
||||
url: 'http://example.com',
|
||||
directory: 'packages/test',
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
packageName: '@other/package',
|
||||
description: 'Overridden description',
|
||||
ownerEntityRefs: ['group:default/test-group'],
|
||||
links: [
|
||||
{
|
||||
title: 'Repository',
|
||||
url: 'http://example.com/',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with custom resolver', () => {
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'custom-resolver',
|
||||
info: {
|
||||
packageJson: async () => ({
|
||||
name: '@test/resolver',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
manifest: async () => ({
|
||||
metadata: {
|
||||
links: [{ title: 'Metadata link', url: 'https://example.com' }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
it('should use the default resolver', async () => {
|
||||
const attacher = createPluginInfoAttacher(mockConfig, async ctx =>
|
||||
ctx.defaultResolver({
|
||||
packageJson: await ctx.packageJson(),
|
||||
manifest: await ctx.manifest(),
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(getInfo(attacher(plugin))).resolves.toEqual({
|
||||
packageName: '@test/resolver',
|
||||
version: '1.0.0',
|
||||
links: [
|
||||
{
|
||||
title: 'Metadata link',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should override info sources passed to default resolver', async () => {
|
||||
const attacher = createPluginInfoAttacher(mockConfig, ctx =>
|
||||
ctx.defaultResolver({
|
||||
packageJson: {
|
||||
name: '@test/resolver-other',
|
||||
version: '2.0.0',
|
||||
},
|
||||
manifest: {
|
||||
metadata: {
|
||||
links: [{ title: 'Other link', url: 'https://example.com' }],
|
||||
},
|
||||
spec: {
|
||||
owner: 'test-group',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(getInfo(attacher(plugin))).resolves.toEqual({
|
||||
packageName: '@test/resolver-other',
|
||||
version: '2.0.0',
|
||||
links: [
|
||||
{
|
||||
title: 'Other link',
|
||||
url: 'https://example.com',
|
||||
},
|
||||
],
|
||||
ownerEntityRefs: ['group:default/test-group'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should use a completely custom resolver', async () => {
|
||||
const attacher = createPluginInfoAttacher(mockConfig, async () => ({
|
||||
info: { version: '0.1.0' },
|
||||
}));
|
||||
|
||||
await expect(getInfo(attacher(plugin))).resolves.toEqual({
|
||||
version: '0.1.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unexpected input from the default resolver', async () => {
|
||||
const attacher = createPluginInfoAttacher(mockConfig, ctx =>
|
||||
ctx.defaultResolver({
|
||||
packageJson: {
|
||||
name: null,
|
||||
version: {},
|
||||
},
|
||||
manifest: {
|
||||
metadata: {
|
||||
links: 'not an array',
|
||||
},
|
||||
spec: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(getInfo(attacher(plugin))).resolves.toEqual({
|
||||
version: '[object Object]',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
* Copyright 2025 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 { ConfigApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
FrontendFeature,
|
||||
FrontendPluginInfo,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { OpaqueFrontendPlugin } from '@internal/frontend';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import once from 'lodash/once';
|
||||
// Avoid full dependency on catalog-model
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import {
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '../../../catalog-model/src/entity/ref';
|
||||
|
||||
/**
|
||||
* A function that resolves plugin info from a plugin manifest and package.json.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FrontendPluginInfoResolver = (ctx: {
|
||||
packageJson(): Promise<JsonObject | undefined>;
|
||||
manifest(): Promise<JsonObject | undefined>;
|
||||
defaultResolver(sources: {
|
||||
packageJson: JsonObject | undefined;
|
||||
manifest: JsonObject | undefined;
|
||||
}): Promise<{ info: FrontendPluginInfo }>;
|
||||
}) => Promise<{ info: FrontendPluginInfo }>;
|
||||
|
||||
export function createPluginInfoAttacher(
|
||||
config: ConfigApi,
|
||||
infoResolver: FrontendPluginInfoResolver = async ctx =>
|
||||
ctx.defaultResolver({
|
||||
packageJson: await ctx.packageJson(),
|
||||
manifest: await ctx.manifest(),
|
||||
}),
|
||||
): (feature: FrontendFeature) => FrontendFeature {
|
||||
const applyInfoOverrides = createPluginInfoOverrider(config);
|
||||
|
||||
return (feature: FrontendFeature) => {
|
||||
if (!OpaqueFrontendPlugin.isType(feature)) {
|
||||
return feature;
|
||||
}
|
||||
|
||||
const plugin = OpaqueFrontendPlugin.toInternal(feature);
|
||||
|
||||
return {
|
||||
...plugin,
|
||||
info: once(async () => {
|
||||
const manifestLoader = plugin.infoOptions?.manifest;
|
||||
const packageJsonLoader = plugin.infoOptions?.packageJson;
|
||||
|
||||
const { info: resolvedInfo } = await infoResolver({
|
||||
manifest: async () => manifestLoader?.(),
|
||||
packageJson: async () => packageJsonLoader?.(),
|
||||
defaultResolver: async sources => ({
|
||||
info: {
|
||||
...resolvePackageInfo(sources.packageJson),
|
||||
...resolveManifestInfo(sources.manifest),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const infoWithOverrides = applyInfoOverrides(plugin.id, resolvedInfo);
|
||||
return normalizePluginInfo(infoWithOverrides);
|
||||
}),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePluginInfo(info: FrontendPluginInfo) {
|
||||
return {
|
||||
...info,
|
||||
ownerEntityRefs: info.ownerEntityRefs?.map(ref =>
|
||||
stringifyEntityRef(
|
||||
parseEntityRef(ref, {
|
||||
defaultKind: 'group',
|
||||
}),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginInfoOverrider(config: ConfigApi) {
|
||||
const overrideConfigs =
|
||||
config.getOptionalConfigArray('app.pluginOverrides') ?? [];
|
||||
|
||||
const overrideMatchers = overrideConfigs.map(overrideConfig => {
|
||||
const pluginIdMatcher = makeStringMatcher(
|
||||
overrideConfig.getOptionalString('match.pluginId'),
|
||||
);
|
||||
const packageNameMatcher = makeStringMatcher(
|
||||
overrideConfig.getOptionalString('match.packageName'),
|
||||
);
|
||||
const description = overrideConfig.getOptionalString('info.description');
|
||||
const ownerEntityRefs = overrideConfig.getOptionalStringArray(
|
||||
'info.ownerEntityRefs',
|
||||
);
|
||||
const links = overrideConfig
|
||||
.getOptionalConfigArray('info.links')
|
||||
?.map(linkConfig => ({
|
||||
title: linkConfig.getString('title'),
|
||||
url: linkConfig.getString('url'),
|
||||
}));
|
||||
|
||||
return {
|
||||
test(pluginId: string, packageName?: string) {
|
||||
return packageNameMatcher(packageName) && pluginIdMatcher(pluginId);
|
||||
},
|
||||
info: {
|
||||
description,
|
||||
ownerEntityRefs,
|
||||
links,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (pluginId: string, info: FrontendPluginInfo) => {
|
||||
const { packageName } = info;
|
||||
for (const matcher of overrideMatchers) {
|
||||
if (matcher.test(pluginId, packageName)) {
|
||||
if (matcher.info.description) {
|
||||
info.description = matcher.info.description;
|
||||
}
|
||||
if (matcher.info.ownerEntityRefs) {
|
||||
info.ownerEntityRefs = matcher.info.ownerEntityRefs;
|
||||
}
|
||||
if (matcher.info.links) {
|
||||
info.links = matcher.info.links;
|
||||
}
|
||||
}
|
||||
}
|
||||
return info;
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManifestInfo(manifest?: JsonValue) {
|
||||
if (!isJsonObject(manifest) || !isJsonObject(manifest.metadata)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const info: FrontendPluginInfo = {};
|
||||
|
||||
if (isJsonObject(manifest.spec) && typeof manifest.spec.owner === 'string') {
|
||||
info.ownerEntityRefs = [
|
||||
stringifyEntityRef(
|
||||
parseEntityRef(manifest.spec.owner, {
|
||||
defaultKind: 'group',
|
||||
defaultNamespace: manifest.metadata.namespace?.toString(),
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (Array.isArray(manifest.metadata.links)) {
|
||||
info.links = manifest.metadata.links.filter(isJsonObject).map(link => ({
|
||||
title: String(link.title),
|
||||
url: String(link.url),
|
||||
}));
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
function resolvePackageInfo(packageJson?: JsonObject) {
|
||||
if (!packageJson) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const info: FrontendPluginInfo = {
|
||||
packageName: packageJson?.name?.toString(),
|
||||
version: packageJson?.version?.toString(),
|
||||
description: packageJson?.description?.toString(),
|
||||
};
|
||||
|
||||
const links: { title: string; url: string }[] = [];
|
||||
|
||||
if (typeof packageJson.homepage === 'string') {
|
||||
links.push({
|
||||
title: 'Homepage',
|
||||
url: packageJson.homepage,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isJsonObject(packageJson.repository) &&
|
||||
typeof packageJson.repository?.url === 'string'
|
||||
) {
|
||||
try {
|
||||
const url = new URL(packageJson.repository?.url);
|
||||
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
||||
// TODO(Rugvip): Support more variants
|
||||
if (
|
||||
url.hostname === 'github.com' &&
|
||||
typeof packageJson.repository.directory === 'string'
|
||||
) {
|
||||
const path = `${url.pathname}/tree/master/${packageJson.repository.directory}`;
|
||||
url.pathname = path.replaceAll('//', '/');
|
||||
}
|
||||
|
||||
links.push({
|
||||
title: 'Repository',
|
||||
url: url.toString(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignored */
|
||||
}
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
info.links = links;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function makeStringMatcher(pattern: string | undefined) {
|
||||
if (!pattern) {
|
||||
return () => true;
|
||||
}
|
||||
if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) {
|
||||
const regex = new RegExp(pattern.slice(1, -1));
|
||||
return (str?: string) => (str ? regex.test(str) : false);
|
||||
}
|
||||
|
||||
return (str?: string) => str === pattern;
|
||||
}
|
||||
|
||||
function isJsonObject(value?: JsonValue): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -671,4 +671,123 @@ describe('createSpecializedApp', () => {
|
||||
|
||||
expect(render(root).container.textContent).toBe('1-2-test-1-2');
|
||||
});
|
||||
|
||||
describe('plugin info', () => {
|
||||
const testExtension = createExtension({
|
||||
attachTo: { id: 'root', input: 'app' },
|
||||
output: [coreExtensionData.reactElement],
|
||||
factory: () => [coreExtensionData.reactElement(<div>Test</div>)],
|
||||
});
|
||||
|
||||
it('should throw unless accessed via an app', async () => {
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
extensions: [testExtension],
|
||||
});
|
||||
|
||||
const errorMsg =
|
||||
"Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app";
|
||||
await expect(plugin.info()).rejects.toThrow(errorMsg);
|
||||
|
||||
const app = createSpecializedApp({ features: [plugin] });
|
||||
|
||||
await expect(plugin.info()).rejects.toThrow(errorMsg);
|
||||
|
||||
const installedPlugin = app.tree.nodes.get('test')?.spec.source;
|
||||
expect(installedPlugin).toBeDefined();
|
||||
const info = await installedPlugin?.info();
|
||||
expect(info).toEqual({});
|
||||
});
|
||||
|
||||
it('should forward plugin info', async () => {
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
info: {
|
||||
packageJson: () => import('../../package.json'),
|
||||
},
|
||||
extensions: [testExtension],
|
||||
});
|
||||
|
||||
const app = createSpecializedApp({ features: [plugin] });
|
||||
const info = await app.tree.nodes.get('test')?.spec.source?.info();
|
||||
expect(info).toMatchObject({
|
||||
packageName: '@backstage/frontend-app-api',
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow overriding plugin info per plugin', async () => {
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
info: {
|
||||
packageJson: () => import('../../package.json'),
|
||||
},
|
||||
extensions: [testExtension],
|
||||
});
|
||||
|
||||
const overriddenPlugin = plugin.withOverrides({
|
||||
extensions: [],
|
||||
info: {
|
||||
packageJson: () => Promise.resolve({ name: 'test-override' }),
|
||||
},
|
||||
});
|
||||
|
||||
const app = createSpecializedApp({ features: [overriddenPlugin] });
|
||||
const info = await app.tree.nodes.get('test')?.spec.source?.info();
|
||||
expect(info).toMatchObject({
|
||||
packageName: 'test-override',
|
||||
});
|
||||
});
|
||||
|
||||
it('should merge with plugin info from manifest', async () => {
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
info: {
|
||||
packageJson: () => import('../../package.json'),
|
||||
manifest: async () => ({
|
||||
metadata: {
|
||||
links: [{ title: 'Example', url: 'https://example.com' }],
|
||||
},
|
||||
spec: {
|
||||
owner: 'cubic-belugas',
|
||||
},
|
||||
}),
|
||||
},
|
||||
extensions: [testExtension],
|
||||
});
|
||||
|
||||
const app = createSpecializedApp({ features: [plugin] });
|
||||
const info = await app.tree.nodes.get('test')?.spec.source?.info();
|
||||
expect(info).toEqual({
|
||||
packageName: '@backstage/frontend-app-api',
|
||||
version: expect.any(String),
|
||||
links: [{ title: 'Example', url: 'https://example.com' }],
|
||||
ownerEntityRefs: ['group:default/cubic-belugas'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should allow overriding of the plugin info resolver', async () => {
|
||||
const plugin = createFrontendPlugin({
|
||||
pluginId: 'test',
|
||||
info: {
|
||||
packageJson: () => import('../../package.json'),
|
||||
},
|
||||
extensions: [testExtension],
|
||||
});
|
||||
|
||||
const app = createSpecializedApp({
|
||||
features: [plugin],
|
||||
async pluginInfoResolver(ctx) {
|
||||
const { info } = await ctx.defaultResolver({
|
||||
packageJson: await ctx.packageJson(),
|
||||
manifest: await ctx.manifest(),
|
||||
});
|
||||
return { info: { packageName: `decorated:${info.packageName}` } };
|
||||
},
|
||||
});
|
||||
const info = await app.tree.nodes.get('test')?.spec.source?.info();
|
||||
expect(info).toEqual({
|
||||
packageName: 'decorated:@backstage/frontend-app-api',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
routeResolutionApiRef,
|
||||
AppNode,
|
||||
ExtensionFactoryMiddleware,
|
||||
FrontendFeature,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import {
|
||||
AnyApiFactory,
|
||||
@@ -74,8 +75,12 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
|
||||
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
|
||||
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
|
||||
import { BackstageRouteObject } from '../routing/types';
|
||||
import { FrontendFeature, RouteInfo } from './types';
|
||||
import { RouteInfo } from './types';
|
||||
import { matchRoutes } from 'react-router-dom';
|
||||
import {
|
||||
createPluginInfoAttacher,
|
||||
FrontendPluginInfoResolver,
|
||||
} from './createPluginInfoAttacher';
|
||||
|
||||
function deduplicateFeatures(
|
||||
allFeatures: FrontendFeature[],
|
||||
@@ -209,9 +214,12 @@ export function createSpecializedApp(options?: {
|
||||
| ExtensionFactoryMiddleware
|
||||
| ExtensionFactoryMiddleware[];
|
||||
flags?: { allowUnknownExtensionConfig?: boolean };
|
||||
pluginInfoResolver?: FrontendPluginInfoResolver;
|
||||
}): { apis: ApiHolder; tree: AppTree } {
|
||||
const config = options?.config ?? new ConfigReader({}, 'empty-config');
|
||||
const features = deduplicateFeatures(options?.features ?? []);
|
||||
const features = deduplicateFeatures(options?.features ?? []).map(
|
||||
createPluginInfoAttacher(config, options?.pluginInfoResolver),
|
||||
);
|
||||
|
||||
const tree = resolveAppTree(
|
||||
'root',
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { createSpecializedApp } from './createSpecializedApp';
|
||||
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
|
||||
export * from './types';
|
||||
|
||||
Reference in New Issue
Block a user