From e3f19dbe68d52479fc227a4fe7934e87b3590ea7 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 4 Mar 2025 16:56:51 +0100 Subject: [PATCH 1/6] Refactor and expose resolveFeatures() from frontend-defaults Signed-off-by: Eric Peterson --- .changeset/mira-color-like.md | 5 ++++ packages/frontend-defaults/report.api.md | 7 +++++ packages/frontend-defaults/src/createApp.tsx | 29 ++++---------------- packages/frontend-defaults/src/discovery.ts | 29 ++++++++++++++++++++ packages/frontend-defaults/src/index.ts | 1 + 5 files changed, 47 insertions(+), 24 deletions(-) create mode 100644 .changeset/mira-color-like.md diff --git a/.changeset/mira-color-like.md b/.changeset/mira-color-like.md new file mode 100644 index 0000000000..fac24490be --- /dev/null +++ b/.changeset/mira-color-like.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': minor +--- + +Feature resolution logic used in `createApp` is now exposed via the `resolveFeatures` function. diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 1c0ee7e6a7..1b8183e7bd 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { Config } from '@backstage/config'; import { ConfigApi } from '@backstage/frontend-plugin-api'; import { CreateAppRouteBinder } from '@backstage/frontend-app-api'; import { ExtensionFactoryMiddleware } from '@backstage/frontend-app-api'; @@ -43,4 +44,10 @@ export interface CreateAppOptions { export function createPublicSignInApp(options?: CreateAppOptions): { createRoot(): React_2.JSX.Element; }; + +// @public (undocumented) +export function resolveFeatures(options: { + config: Config; + providedFeatures?: (FrontendFeature | CreateAppFeatureLoader)[]; +}): Promise; ``` diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index a8b40a5f63..0eb4d4a8e8 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -16,14 +16,12 @@ import React, { JSX, ReactNode } from 'react'; import { ConfigApi, coreExtensionData } from '@backstage/frontend-plugin-api'; -import { stringifyError } from '@backstage/errors'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseUrlConfigs'; -import { getAvailableFeatures } from './discovery'; +import { resolveFeatures } from './discovery'; import { ConfigReader } from '@backstage/config'; -import appPlugin from '@backstage/plugin-app'; import { CreateAppRouteBinder, ExtensionFactoryMiddleware, @@ -89,29 +87,12 @@ export function createApp(options?: CreateAppOptions): { overrideBaseUrlConfigs(defaultConfigLoaderSync()), ); - const discoveredFeatures = getAvailableFeatures(config); - - const providedFeatures: FrontendFeature[] = []; - for (const entry of options?.features ?? []) { - if ('load' in entry) { - try { - const result = await entry.load({ config }); - providedFeatures.push(...result.features); - } catch (e) { - throw new Error( - `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( - e, - )}`, - ); - } - } else { - providedFeatures.push(entry); - } - } - const app = createSpecializedApp({ config, - features: [appPlugin, ...discoveredFeatures, ...providedFeatures], + features: await resolveFeatures({ + config, + providedFeatures: options?.features, + }), bindRoutes: options?.bindRoutes, extensionFactoryMiddleware: options?.extensionFactoryMiddleware, }); diff --git a/packages/frontend-defaults/src/discovery.ts b/packages/frontend-defaults/src/discovery.ts index e04960dc48..b80f9a1b39 100644 --- a/packages/frontend-defaults/src/discovery.ts +++ b/packages/frontend-defaults/src/discovery.ts @@ -15,7 +15,10 @@ */ import { Config, ConfigReader } from '@backstage/config'; +import { stringifyError } from '@backstage/errors'; import { FrontendFeature } from '@backstage/frontend-app-api'; +import { CreateAppFeatureLoader } from './createApp'; +import appPlugin from '@backstage/plugin-app'; interface DiscoveryGlobal { modules: Array<{ name: string; export?: string; default: unknown }>; @@ -94,3 +97,29 @@ function isBackstageFeature(obj: unknown): obj is FrontendFeature { } return false; } + +/** @public */ +export async function resolveFeatures(options: { + config: Config; + providedFeatures?: (FrontendFeature | CreateAppFeatureLoader)[]; +}): Promise { + const discoveredFeatures = getAvailableFeatures(options.config); + const features = [appPlugin, ...discoveredFeatures]; + for (const entry of options.providedFeatures ?? []) { + if ('load' in entry) { + try { + const result = await entry.load({ config: options.config }); + features.push(...result.features); + } catch (e) { + throw new Error( + `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( + e, + )}`, + ); + } + } else { + features.push(entry); + } + } + return features; +} diff --git a/packages/frontend-defaults/src/index.ts b/packages/frontend-defaults/src/index.ts index 7ee60a2116..ff233e00b3 100644 --- a/packages/frontend-defaults/src/index.ts +++ b/packages/frontend-defaults/src/index.ts @@ -26,3 +26,4 @@ export { type CreateAppFeatureLoader, } from './createApp'; export { createPublicSignInApp } from './createPublicSignInApp'; +export { resolveFeatures } from './discovery'; From f008f26970dfe2722603d01c2ecf5d87d0e68135 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 6 Mar 2025 09:15:57 +0100 Subject: [PATCH 2/6] Better option names Signed-off-by: Eric Peterson --- .changeset/mira-color-like.md | 2 +- packages/frontend-defaults/report.api.md | 2 +- packages/frontend-defaults/src/createApp.tsx | 2 +- packages/frontend-defaults/src/discovery.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/mira-color-like.md b/.changeset/mira-color-like.md index fac24490be..45bff186f1 100644 --- a/.changeset/mira-color-like.md +++ b/.changeset/mira-color-like.md @@ -1,5 +1,5 @@ --- -'@backstage/frontend-defaults': minor +'@backstage/frontend-defaults': patch --- Feature resolution logic used in `createApp` is now exposed via the `resolveFeatures` function. diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index 1b8183e7bd..ac9c7a7312 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -48,6 +48,6 @@ export function createPublicSignInApp(options?: CreateAppOptions): { // @public (undocumented) export function resolveFeatures(options: { config: Config; - providedFeatures?: (FrontendFeature | CreateAppFeatureLoader)[]; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; }): Promise; ``` diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 0eb4d4a8e8..ae524e93e2 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -91,7 +91,7 @@ export function createApp(options?: CreateAppOptions): { config, features: await resolveFeatures({ config, - providedFeatures: options?.features, + features: options?.features, }), bindRoutes: options?.bindRoutes, extensionFactoryMiddleware: options?.extensionFactoryMiddleware, diff --git a/packages/frontend-defaults/src/discovery.ts b/packages/frontend-defaults/src/discovery.ts index b80f9a1b39..c71d7d15ea 100644 --- a/packages/frontend-defaults/src/discovery.ts +++ b/packages/frontend-defaults/src/discovery.ts @@ -101,11 +101,11 @@ function isBackstageFeature(obj: unknown): obj is FrontendFeature { /** @public */ export async function resolveFeatures(options: { config: Config; - providedFeatures?: (FrontendFeature | CreateAppFeatureLoader)[]; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; }): Promise { const discoveredFeatures = getAvailableFeatures(options.config); const features = [appPlugin, ...discoveredFeatures]; - for (const entry of options.providedFeatures ?? []) { + for (const entry of options.features ?? []) { if ('load' in entry) { try { const result = await entry.load({ config: options.config }); From 55e8568ff54e50ba3c3169500fecd212a0693700 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 10 Mar 2025 09:51:47 +0100 Subject: [PATCH 3/6] Keep feature discovery and resolution logic separate Signed-off-by: Eric Peterson --- .changeset/mira-color-like.md | 2 +- packages/frontend-defaults/report.api.md | 3 ++ packages/frontend-defaults/src/createApp.tsx | 15 ++++--- packages/frontend-defaults/src/discovery.ts | 31 +------------ packages/frontend-defaults/src/index.ts | 3 +- .../frontend-defaults/src/resolveFeatures.ts | 45 +++++++++++++++++++ 6 files changed, 62 insertions(+), 37 deletions(-) create mode 100644 packages/frontend-defaults/src/resolveFeatures.ts diff --git a/.changeset/mira-color-like.md b/.changeset/mira-color-like.md index 45bff186f1..7bdf035407 100644 --- a/.changeset/mira-color-like.md +++ b/.changeset/mira-color-like.md @@ -2,4 +2,4 @@ '@backstage/frontend-defaults': patch --- -Feature resolution logic used in `createApp` is now exposed via the `resolveFeatures` function. +Feature discovery and resolution logic used in `createApp` is now exposed via the `getAvailableFeatures` and `resolveFeatures` functions respectively. diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index ac9c7a7312..a65d1a5d1f 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -45,6 +45,9 @@ export function createPublicSignInApp(options?: CreateAppOptions): { createRoot(): React_2.JSX.Element; }; +// @public (undocumented) +export function getAvailableFeatures(config: Config): FrontendFeature[]; + // @public (undocumented) export function resolveFeatures(options: { config: Config; diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index ae524e93e2..70c308518e 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -20,7 +20,6 @@ import { ConfigApi, coreExtensionData } from '@backstage/frontend-plugin-api'; import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseUrlConfigs'; -import { resolveFeatures } from './discovery'; import { ConfigReader } from '@backstage/config'; import { CreateAppRouteBinder, @@ -28,6 +27,9 @@ import { FrontendFeature, createSpecializedApp, } from '@backstage/frontend-app-api'; +import appPlugin from '@backstage/plugin-app'; +import { getAvailableFeatures } from './discovery'; +import { resolveFeatures } from './resolveFeatures'; /** * A source of dynamically loaded frontend features. @@ -87,12 +89,15 @@ export function createApp(options?: CreateAppOptions): { overrideBaseUrlConfigs(defaultConfigLoaderSync()), ); + const discoveredFeatures = getAvailableFeatures(config); + const providedFeatures = await resolveFeatures({ + config, + features: options?.features, + }); + const app = createSpecializedApp({ config, - features: await resolveFeatures({ - config, - features: options?.features, - }), + features: [appPlugin, ...discoveredFeatures, ...providedFeatures], bindRoutes: options?.bindRoutes, extensionFactoryMiddleware: options?.extensionFactoryMiddleware, }); diff --git a/packages/frontend-defaults/src/discovery.ts b/packages/frontend-defaults/src/discovery.ts index c71d7d15ea..6f9d2e2d16 100644 --- a/packages/frontend-defaults/src/discovery.ts +++ b/packages/frontend-defaults/src/discovery.ts @@ -15,10 +15,7 @@ */ import { Config, ConfigReader } from '@backstage/config'; -import { stringifyError } from '@backstage/errors'; import { FrontendFeature } from '@backstage/frontend-app-api'; -import { CreateAppFeatureLoader } from './createApp'; -import appPlugin from '@backstage/plugin-app'; interface DiscoveryGlobal { modules: Array<{ name: string; export?: string; default: unknown }>; @@ -56,7 +53,7 @@ function readPackageDetectionConfig(config: Config) { } /** - * @internal + * @public */ export function getAvailableFeatures(config: Config): FrontendFeature[] { const discovered = ( @@ -97,29 +94,3 @@ function isBackstageFeature(obj: unknown): obj is FrontendFeature { } return false; } - -/** @public */ -export async function resolveFeatures(options: { - config: Config; - features?: (FrontendFeature | CreateAppFeatureLoader)[]; -}): Promise { - const discoveredFeatures = getAvailableFeatures(options.config); - const features = [appPlugin, ...discoveredFeatures]; - for (const entry of options.features ?? []) { - if ('load' in entry) { - try { - const result = await entry.load({ config: options.config }); - features.push(...result.features); - } catch (e) { - throw new Error( - `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( - e, - )}`, - ); - } - } else { - features.push(entry); - } - } - return features; -} diff --git a/packages/frontend-defaults/src/index.ts b/packages/frontend-defaults/src/index.ts index ff233e00b3..c65e053dd5 100644 --- a/packages/frontend-defaults/src/index.ts +++ b/packages/frontend-defaults/src/index.ts @@ -26,4 +26,5 @@ export { type CreateAppFeatureLoader, } from './createApp'; export { createPublicSignInApp } from './createPublicSignInApp'; -export { resolveFeatures } from './discovery'; +export { getAvailableFeatures } from './discovery'; +export { resolveFeatures } from './resolveFeatures'; diff --git a/packages/frontend-defaults/src/resolveFeatures.ts b/packages/frontend-defaults/src/resolveFeatures.ts new file mode 100644 index 0000000000..dddf0d2a1b --- /dev/null +++ b/packages/frontend-defaults/src/resolveFeatures.ts @@ -0,0 +1,45 @@ +/* + * 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 { Config } from '@backstage/config'; +import { stringifyError } from '@backstage/errors'; +import { FrontendFeature } from '@backstage/frontend-app-api'; +import { CreateAppFeatureLoader } from './createApp'; + +/** @public */ +export async function resolveFeatures(options: { + config: Config; + features?: (FrontendFeature | CreateAppFeatureLoader)[]; +}): Promise { + const features = []; + for (const entry of options.features ?? []) { + if ('load' in entry) { + try { + const result = await entry.load({ config: options.config }); + features.push(...result.features); + } catch (e) { + throw new Error( + `Failed to read frontend features from loader '${entry.getLoaderName()}', ${stringifyError( + e, + )}`, + ); + } + } else { + features.push(entry); + } + } + return features; +} From a2544d59f5f8db3bac5b982407f9d4b8afe8b4ea Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 10 Mar 2025 10:14:42 +0100 Subject: [PATCH 4/6] Add tests for feature resolver Signed-off-by: Eric Peterson --- .../src/resolveFeatures.test.ts | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/frontend-defaults/src/resolveFeatures.test.ts diff --git a/packages/frontend-defaults/src/resolveFeatures.test.ts b/packages/frontend-defaults/src/resolveFeatures.test.ts new file mode 100644 index 0000000000..b91e6710b6 --- /dev/null +++ b/packages/frontend-defaults/src/resolveFeatures.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { + createFrontendPlugin, + PageBlueprint, +} from '@backstage/frontend-plugin-api'; +import { CreateAppFeatureLoader } from './createApp'; +import { resolveFeatures } from './resolveFeatures'; +import { mockApis } from '@backstage/test-utils'; + +describe('resolveFeatures', () => { + it('returns empty array when no features are provided', async () => { + const features = await resolveFeatures({ + config: mockApis.config(), + }); + + expect(features).toEqual([]); + }); + + it('returns expected array when features are directly provided', async () => { + const features = await resolveFeatures({ + config: mockApis.config(), + features: [ + createFrontendPlugin({ + id: 'test-feature', + extensions: [ + PageBlueprint.make({ + params: { + defaultPath: '/', + loader: () => new Promise(() => {}), + }, + }), + ], + }), + ], + }); + + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'test-feature', + version: 'v1', + extensions: [ + { + $$type: '@backstage/Extension', + id: 'page:test-feature', + version: 'v2', + attachTo: { + id: 'app/routes', + input: 'routes', + }, + }, + ], + }, + ]); + }); + + it('supports feature loaders', async () => { + const loader: CreateAppFeatureLoader = { + getLoaderName() { + return 'test-loader'; + }, + async load() { + return { + features: [ + createFrontendPlugin({ + id: 'test', + extensions: [ + PageBlueprint.make({ + params: { + defaultPath: '/', + loader: () => new Promise(() => {}), + }, + }), + ], + }), + ], + }; + }, + }; + + const features = await resolveFeatures({ + config: mockApis.config(), + features: [loader], + }); + + expect(features).toMatchObject([ + { + $$type: '@backstage/FrontendPlugin', + id: 'test', + version: 'v1', + extensions: [ + { + $$type: '@backstage/Extension', + id: 'page:test', + version: 'v2', + attachTo: { + id: 'app/routes', + input: 'routes', + }, + }, + ], + }, + ]); + }); + + it('should propagate errors thrown by feature loaders', async () => { + const loader: CreateAppFeatureLoader = { + getLoaderName() { + return 'test-loader'; + }, + async load() { + throw new TypeError('boom'); + }, + }; + + await expect(() => + resolveFeatures({ + config: mockApis.config(), + features: [loader], + }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to read frontend features from loader 'test-loader', TypeError: boom"`, + ); + }); +}); From e41c80d8a0c1184a20e0e9314bc6c6108954cb1a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 10 Mar 2025 12:38:56 +0100 Subject: [PATCH 5/6] Clean up API surface Signed-off-by: Eric Peterson --- packages/frontend-defaults/report.api.md | 10 ++++-- packages/frontend-defaults/src/createApp.tsx | 8 ++--- .../frontend-defaults/src/discovery.test.ts | 34 +++++++++--------- packages/frontend-defaults/src/discovery.ts | 35 ++++++++++--------- packages/frontend-defaults/src/index.ts | 4 +-- ...lveFeatures.test.ts => resolution.test.ts} | 12 +++---- .../src/{resolveFeatures.ts => resolution.ts} | 6 ++-- 7 files changed, 58 insertions(+), 51 deletions(-) rename packages/frontend-defaults/src/{resolveFeatures.test.ts => resolution.test.ts} (92%) rename packages/frontend-defaults/src/{resolveFeatures.ts => resolution.ts} (91%) diff --git a/packages/frontend-defaults/report.api.md b/packages/frontend-defaults/report.api.md index a65d1a5d1f..5d19d9bc98 100644 --- a/packages/frontend-defaults/report.api.md +++ b/packages/frontend-defaults/report.api.md @@ -46,11 +46,15 @@ export function createPublicSignInApp(options?: CreateAppOptions): { }; // @public (undocumented) -export function getAvailableFeatures(config: Config): FrontendFeature[]; +export function discoverAvailableFeatures(config: Config): { + features: FrontendFeature[]; +}; // @public (undocumented) -export function resolveFeatures(options: { +export function resolveAsyncFeatures(options: { config: Config; features?: (FrontendFeature | CreateAppFeatureLoader)[]; -}): Promise; +}): Promise<{ + features: FrontendFeature[]; +}>; ``` diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 70c308518e..d63f254a49 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -28,8 +28,8 @@ import { createSpecializedApp, } from '@backstage/frontend-app-api'; import appPlugin from '@backstage/plugin-app'; -import { getAvailableFeatures } from './discovery'; -import { resolveFeatures } from './resolveFeatures'; +import { discoverAvailableFeatures } from './discovery'; +import { resolveAsyncFeatures } from './resolution'; /** * A source of dynamically loaded frontend features. @@ -89,8 +89,8 @@ export function createApp(options?: CreateAppOptions): { overrideBaseUrlConfigs(defaultConfigLoaderSync()), ); - const discoveredFeatures = getAvailableFeatures(config); - const providedFeatures = await resolveFeatures({ + const { features: discoveredFeatures } = discoverAvailableFeatures(config); + const { features: providedFeatures } = await resolveAsyncFeatures({ config, features: options?.features, }); diff --git a/packages/frontend-defaults/src/discovery.test.ts b/packages/frontend-defaults/src/discovery.test.ts index 8570ca1bc7..80be0b25c5 100644 --- a/packages/frontend-defaults/src/discovery.test.ts +++ b/packages/frontend-defaults/src/discovery.test.ts @@ -15,7 +15,7 @@ */ import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; -import { getAvailableFeatures } from './discovery'; +import { discoverAvailableFeatures } from './discovery'; import { ConfigReader } from '@backstage/config'; const globalSpy = jest.fn(); @@ -27,18 +27,18 @@ const config = new ConfigReader({ app: { experimental: { packages: 'all' } }, }); -describe('getAvailableFeatures', () => { +describe('discoverAvailableFeatures', () => { afterEach(jest.resetAllMocks); it('should discover nothing with undefined global', () => { - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); }); it('should discover nothing with empty global', () => { globalSpy.mockReturnValue({ modules: [], }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); }); it('should discover a plugin', () => { @@ -46,24 +46,26 @@ describe('getAvailableFeatures', () => { globalSpy.mockReturnValue({ modules: [{ default: testPlugin }], }); - expect(getAvailableFeatures(config)).toEqual([testPlugin]); + expect(discoverAvailableFeatures(config)).toEqual({ + features: [testPlugin], + }); }); it('should ignore garbage', () => { globalSpy.mockReturnValueOnce({ modules: [{ default: null }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: undefined }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: Symbol() }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: () => {} }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: 0 }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: false }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); globalSpy.mockReturnValueOnce({ modules: [{ default: true }] }); - expect(getAvailableFeatures(config)).toEqual([]); + expect(discoverAvailableFeatures(config)).toEqual({ features: [] }); }); it('should discover multiple plugins', () => { @@ -77,10 +79,8 @@ describe('getAvailableFeatures', () => { { default: test3Plugin }, ], }); - expect(getAvailableFeatures(config)).toEqual([ - test1Plugin, - test2Plugin, - test3Plugin, - ]); + expect(discoverAvailableFeatures(config)).toEqual({ + features: [test1Plugin, test2Plugin, test3Plugin], + }); }); }); diff --git a/packages/frontend-defaults/src/discovery.ts b/packages/frontend-defaults/src/discovery.ts index 6f9d2e2d16..e82f2acfda 100644 --- a/packages/frontend-defaults/src/discovery.ts +++ b/packages/frontend-defaults/src/discovery.ts @@ -55,30 +55,33 @@ function readPackageDetectionConfig(config: Config) { /** * @public */ -export function getAvailableFeatures(config: Config): FrontendFeature[] { +export function discoverAvailableFeatures(config: Config): { + features: FrontendFeature[]; +} { const discovered = ( window as { '__@backstage/discovered__'?: DiscoveryGlobal } )['__@backstage/discovered__']; const detection = readPackageDetectionConfig(config); if (!detection) { - return []; + return { features: [] }; } - return ( - discovered?.modules - .filter(({ name }) => { - if (detection.exclude?.includes(name)) { - return false; - } - if (detection.include && !detection.include.includes(name)) { - return false; - } - return true; - }) - .map(m => m.default) - .filter(isBackstageFeature) ?? [] - ); + return { + features: + discovered?.modules + .filter(({ name }) => { + if (detection.exclude?.includes(name)) { + return false; + } + if (detection.include && !detection.include.includes(name)) { + return false; + } + return true; + }) + .map(m => m.default) + .filter(isBackstageFeature) ?? [], + }; } function isBackstageFeature(obj: unknown): obj is FrontendFeature { diff --git a/packages/frontend-defaults/src/index.ts b/packages/frontend-defaults/src/index.ts index c65e053dd5..573cc9b08c 100644 --- a/packages/frontend-defaults/src/index.ts +++ b/packages/frontend-defaults/src/index.ts @@ -26,5 +26,5 @@ export { type CreateAppFeatureLoader, } from './createApp'; export { createPublicSignInApp } from './createPublicSignInApp'; -export { getAvailableFeatures } from './discovery'; -export { resolveFeatures } from './resolveFeatures'; +export { discoverAvailableFeatures } from './discovery'; +export { resolveAsyncFeatures } from './resolution'; diff --git a/packages/frontend-defaults/src/resolveFeatures.test.ts b/packages/frontend-defaults/src/resolution.test.ts similarity index 92% rename from packages/frontend-defaults/src/resolveFeatures.test.ts rename to packages/frontend-defaults/src/resolution.test.ts index b91e6710b6..d833716666 100644 --- a/packages/frontend-defaults/src/resolveFeatures.test.ts +++ b/packages/frontend-defaults/src/resolution.test.ts @@ -19,12 +19,12 @@ import { PageBlueprint, } from '@backstage/frontend-plugin-api'; import { CreateAppFeatureLoader } from './createApp'; -import { resolveFeatures } from './resolveFeatures'; +import { resolveAsyncFeatures } from './resolution'; import { mockApis } from '@backstage/test-utils'; -describe('resolveFeatures', () => { +describe('resolveAsyncFeatures', () => { it('returns empty array when no features are provided', async () => { - const features = await resolveFeatures({ + const { features } = await resolveAsyncFeatures({ config: mockApis.config(), }); @@ -32,7 +32,7 @@ describe('resolveFeatures', () => { }); it('returns expected array when features are directly provided', async () => { - const features = await resolveFeatures({ + const { features } = await resolveAsyncFeatures({ config: mockApis.config(), features: [ createFrontendPlugin({ @@ -93,7 +93,7 @@ describe('resolveFeatures', () => { }, }; - const features = await resolveFeatures({ + const { features } = await resolveAsyncFeatures({ config: mockApis.config(), features: [loader], }); @@ -129,7 +129,7 @@ describe('resolveFeatures', () => { }; await expect(() => - resolveFeatures({ + resolveAsyncFeatures({ config: mockApis.config(), features: [loader], }), diff --git a/packages/frontend-defaults/src/resolveFeatures.ts b/packages/frontend-defaults/src/resolution.ts similarity index 91% rename from packages/frontend-defaults/src/resolveFeatures.ts rename to packages/frontend-defaults/src/resolution.ts index dddf0d2a1b..e51ecbc2d3 100644 --- a/packages/frontend-defaults/src/resolveFeatures.ts +++ b/packages/frontend-defaults/src/resolution.ts @@ -20,10 +20,10 @@ import { FrontendFeature } from '@backstage/frontend-app-api'; import { CreateAppFeatureLoader } from './createApp'; /** @public */ -export async function resolveFeatures(options: { +export async function resolveAsyncFeatures(options: { config: Config; features?: (FrontendFeature | CreateAppFeatureLoader)[]; -}): Promise { +}): Promise<{ features: FrontendFeature[] }> { const features = []; for (const entry of options.features ?? []) { if ('load' in entry) { @@ -41,5 +41,5 @@ export async function resolveFeatures(options: { features.push(entry); } } - return features; + return { features }; } From 177b0782d5ba59a5af3122f6f799db0b0d77ef5f Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 10 Mar 2025 14:34:16 +0100 Subject: [PATCH 6/6] Fix functions named in changeset Signed-off-by: Eric Peterson --- .changeset/mira-color-like.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mira-color-like.md b/.changeset/mira-color-like.md index 7bdf035407..f6f82f44d7 100644 --- a/.changeset/mira-color-like.md +++ b/.changeset/mira-color-like.md @@ -2,4 +2,4 @@ '@backstage/frontend-defaults': patch --- -Feature discovery and resolution logic used in `createApp` is now exposed via the `getAvailableFeatures` and `resolveFeatures` functions respectively. +Feature discovery and resolution logic used in `createApp` is now exposed via the `discoverAvailableFeatures` and `resolveAsyncFeatures` functions respectively.