From 1c2cc37a705b7f17e155857c37c9a5a26c6103fe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 7 Aug 2025 00:18:20 +0200 Subject: [PATCH] frontend-*-api: better error when factories return invalid data Signed-off-by: Patrik Oldsberg --- .changeset/tangy-pets-smoke.md | 6 + .../src/tree/instantiateAppNodeTree.test.ts | 124 ++++++++++++++++++ .../src/tree/instantiateAppNodeTree.ts | 9 ++ .../src/wiring/createSpecializedApp.tsx | 1 + .../wiring/createExtensionDataContainer.ts | 5 + .../src/wiring/createExtension.ts | 10 ++ .../wiring/createExtensionBlueprint.test.tsx | 2 +- .../src/wiring/createExtensionBlueprint.ts | 1 + .../src/wiring/resolveInputOverrides.ts | 2 + 9 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 .changeset/tangy-pets-smoke.md diff --git a/.changeset/tangy-pets-smoke.md b/.changeset/tangy-pets-smoke.md new file mode 100644 index 0000000000..f7438da333 --- /dev/null +++ b/.changeset/tangy-pets-smoke.md @@ -0,0 +1,6 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/frontend-app-api': patch +--- + +Improved runtime error message clarity when extension factories don't return an iterable object. diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index f3201e97b2..5fa22536f8 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -18,10 +18,13 @@ import { AppNode, Extension, ExtensionDataRef, + ExtensionDefinition, + ExtensionFactoryMiddleware, ExtensionInput, PortableSchema, ResolvedExtensionInput, createExtension, + createExtensionBlueprint, createExtensionDataRef, createExtensionInput, } from '@backstage/frontend-plugin-api'; @@ -957,6 +960,127 @@ describe('instantiateAppNodeTree', () => { ); }); + it('should throw if extension factories do not provide an iterable object', () => { + function createInstance( + extension: ExtensionDefinition, + middleware?: ExtensionFactoryMiddleware, + ) { + return createAppNodeInstance({ + extensionFactoryMiddleware: middleware, + apis: testApis, + node: makeNode( + resolveExtensionDefinition(extension, { namespace: 'test' }), + ), + attachments: new Map(), + }); + } + + const baseOpts = { + attachTo: { id: 'ignored', input: 'ignored' }, + output: [testDataRef], + }; + + const badFactory = () => 'not-iterable' as any; + const goodFactory = () => [testDataRef('test')]; + + expect(() => + createInstance( + createExtension({ + attachTo: { id: 'ignored', input: 'ignored' }, + output: [testDataRef], + factory: badFactory, + }), + ), + ).toThrow( + `Failed to instantiate extension 'test', extension factory did not provide an iterable object`, + ); + + expect(() => + createInstance( + createExtension({ + ...baseOpts, + factory: goodFactory, + }).override({ + factory: badFactory, + }), + ), + ).toThrow( + `Failed to instantiate extension 'test', extension factory override did not provide an iterable object`, + ); + + // Bad middleware + expect(() => + createInstance( + createExtension({ + ...baseOpts, + factory: goodFactory, + }), + () => 'not-iterable' as any, + ), + ).toThrow( + `Failed to instantiate extension 'test', extension factory middleware did not provide an iterable object`, + ); + + expect(() => + createInstance( + createExtensionBlueprint({ + kind: 'test', + ...baseOpts, + factory: badFactory, + }).make({ params: {} }), + ), + ).toThrow( + `Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`, + ); + + // Using makeWithOverrides + expect(() => + createInstance( + createExtensionBlueprint({ + kind: 'test', + ...baseOpts, + factory: goodFactory, + }).makeWithOverrides({ + factory: badFactory, + }), + ), + ).toThrow( + `Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`, + ); + + // Using makeWithOverrides and factory middleware + expect(() => + createInstance( + createExtensionBlueprint({ + kind: 'test', + ...baseOpts, + factory: goodFactory, + }).makeWithOverrides({ + factory: badFactory, + }), + orig => orig(), + ), + ).toThrow( + `Failed to instantiate extension 'test:test', extension factory did not provide an iterable object`, + ); + + // Using makeWithOverrides and factory middleware + expect(() => + createInstance( + createExtensionBlueprint({ + kind: 'test', + ...baseOpts, + factory: badFactory, + }).makeWithOverrides({ + factory: orig => orig({ params: {} }), + }), + orig => orig(), + ), + ).toThrow( + `Failed to instantiate extension 'test:test', original blueprint factory did not provide an iterable object`, + ); + }); + it('should forward extension factory errors', () => { expect(() => createAppNodeInstance({ diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 408a62702a..2c917ed5d4 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -309,11 +309,20 @@ export function createAppNodeInstance(options: { inputs: context.inputs, config: overrideContext?.config ?? context.config, }), + 'extension factory', ); }, context), + 'extension factory middleware', ) : internalExtension.factory(context); + if ( + typeof outputDataValues !== 'object' || + !outputDataValues?.[Symbol.iterator] + ) { + throw new Error('extension factory did not provide an iterable object'); + } + const outputDataMap = new Map(); for (const value of outputDataValues) { if (outputDataMap.has(value.id)) { diff --git a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx index e5c565f117..ac441a97f9 100644 --- a/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/createSpecializedApp.tsx @@ -373,6 +373,7 @@ function mergeExtensionFactoryMiddleware( apis: ctx.apis, config: ctxOverrides?.config ?? ctx.config, }), + 'extension factory middleware', ); }, ctx); }; diff --git a/packages/frontend-internal/src/wiring/createExtensionDataContainer.ts b/packages/frontend-internal/src/wiring/createExtensionDataContainer.ts index e378592cf6..34eeca75de 100644 --- a/packages/frontend-internal/src/wiring/createExtensionDataContainer.ts +++ b/packages/frontend-internal/src/wiring/createExtensionDataContainer.ts @@ -26,8 +26,13 @@ export function createExtensionDataContainer( ? ExtensionDataValue : never >, + contextName: string, declaredRefs?: ExtensionDataRef[], ): ExtensionDataContainer { + if (typeof values !== 'object' || !values?.[Symbol.iterator]) { + throw new Error(`${contextName} did not provide an iterable object`); + } + const container = new Map>(); const verifyRefs = declaredRefs && new Map(declaredRefs.map(ref => [ref.id, ref])); diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 116cd02d6d..8a5cb9e8f2 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -448,6 +448,7 @@ export function createExtension< ) as any, [ctxParamsSymbol as any]: innerContext?.params, }) as Iterable, + 'original extension factory', options.output, ); }, @@ -459,6 +460,15 @@ export function createExtension< }, ); + if ( + typeof parentResult !== 'object' || + !parentResult?.[Symbol.iterator] + ) { + throw new Error( + 'extension factory override did not provide an iterable object', + ); + } + const deduplicatedResult = new Map< string, ExtensionDataValue diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index bd1dd26423..e4cc876ff0 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -391,7 +391,7 @@ describe('createExtensionBlueprint', () => { }); const mockInput = (node: string, ...data: ExtensionDataValue[]) => - Object.assign(createExtensionDataContainer(data), { + Object.assign(createExtensionDataContainer(data, 'mock'), { node, }); const mockParentInputs = { diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 32b64798c8..f94551755f 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -521,6 +521,7 @@ export function createExtensionBlueprint< ) as any, }, ) as Iterable, + 'original blueprint factory', options.output, ); }, diff --git a/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts b/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts index d786ac66e8..9a2d54db9c 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveInputOverrides.ts @@ -119,6 +119,7 @@ export function resolveInputOverrides( if (providedData) { const providedContainer = createExtensionDataContainer( providedData as Iterable>, + 'extension input override', declaredInput.extensionData, ); if (!originalInput) { @@ -157,6 +158,7 @@ export function resolveInputOverrides( newInputs[name] = providedData.map((data, i) => { const providedContainer = createExtensionDataContainer( data as Iterable>, + 'extension input override', declaredInput.extensionData, ); return Object.assign(providedContainer, {