diff --git a/.changeset/good-eggs-tell.md b/.changeset/good-eggs-tell.md new file mode 100644 index 0000000000..86767c24a4 --- /dev/null +++ b/.changeset/good-eggs-tell.md @@ -0,0 +1,8 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Updated error reporting and app tree resolution logic to attribute errors to the correct extension and allow app startup to proceed more optimistically: + +- If an attachment fails to provide the required input data, the error is now attributed to the attachment rather than the parent extension. +- Singleton extension inputs will now only forward attachment errors if the input is required. diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts index a07fdf352d..d15873f5d1 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.test.ts @@ -1574,41 +1574,109 @@ describe('instantiateAppNodeTree', () => { ]); }); - it('should refuse to create an instance with multiple inputs that did not provide required data', () => { - const node = makeNode( - resolveExtensionDefinition( - createExtension({ - name: 'test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput([otherDataRef], { - singleton: true, - }), - }, - output: [], - factory: () => [], - }), - { namespace: 'app' }, - ), + describe('with attachment failures', () => { + const inputCountRef = createExtensionDataRef().with({ + id: 'input-count', + }); + + const attachmentWithoutRequiredData = makeInstanceWithId( + simpleExtension, + undefined, ); - expect( - createAppNodeInstance({ - apis: testApis, - attachments: new Map([ - ['singleton', [makeInstanceWithId(simpleExtension, undefined)]], - ]), - node, - collector, - }), - ).toBeUndefined(); - expect(collector.collectErrors()).toEqual([ - { - code: 'EXTENSION_INPUT_DATA_MISSING', - message: - "extension 'app/test' could not be attached because its output data ('test') does not match what the input 'singleton' requires ('other')", - context: { node, inputName: 'singleton' }, - }, - ]); + + it('should proceed if input is optional', () => { + const node = makeNode( + resolveExtensionDefinition( + createExtension({ + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput([otherDataRef], { + singleton: true, + optional: true, + }), + }, + output: [inputCountRef], + factory: ({ inputs }) => [ + inputCountRef(inputs.singleton ? 1 : 0), + ], + }), + { namespace: 'app' }, + ), + ); + expect( + createAppNodeInstance({ + apis: testApis, + attachments: new Map([ + ['singleton', [attachmentWithoutRequiredData]], + ]), + node, + collector, + })?.getData(inputCountRef), + ).toBe(0); + + expect(collector.collectErrors()).toEqual([ + { + code: 'EXTENSION_INPUT_DATA_MISSING', + message: + "extension 'app/test' could not be attached because its output data ('test') does not match what the input 'singleton' requires ('other')", + context: { + node: attachmentWithoutRequiredData, + inputName: 'singleton', + }, + }, + ]); + }); + + it('should fail if input is required', () => { + const node = makeNode( + resolveExtensionDefinition( + createExtension({ + name: 'test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput([otherDataRef], { + singleton: true, + }), + }, + output: [inputCountRef], + factory: ({ inputs }) => [ + inputCountRef(inputs.singleton ? 1 : 0), + ], + }), + { namespace: 'app' }, + ), + ); + + expect( + createAppNodeInstance({ + apis: testApis, + attachments: new Map([ + ['singleton', [attachmentWithoutRequiredData]], + ]), + node, + collector, + })?.getData(inputCountRef), + ).toBeUndefined(); + + expect(collector.collectErrors()).toEqual([ + { + code: 'EXTENSION_ATTACHMENT_MISSING', + message: + "input 'singleton' is required but it failed to be instantiated", + context: { inputName: 'singleton', node }, + }, + { + code: 'EXTENSION_INPUT_DATA_MISSING', + message: + "extension 'app/test' could not be attached because its output data ('test') does not match what the input 'singleton' requires ('other')", + context: { + node: attachmentWithoutRequiredData, + inputName: 'singleton', + }, + }, + ]); + }); }); }); }); diff --git a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts index 04082f8520..55bea60274 100644 --- a/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts +++ b/packages/frontend-app-api/src/tree/instantiateAppNodeTree.ts @@ -119,7 +119,7 @@ function resolveInputDataContainer( .map(r => `'${r.id}'`) .join(', '); - collector.report({ + collector.child({ node: attachment }).report({ code: 'EXTENSION_INPUT_DATA_MISSING', message: `extension '${attachment.spec.id}' could not be attached because its output data (${provided}) does not match what the input '${inputName}' requires (${expected})`, }); @@ -271,12 +271,25 @@ function resolveV2Inputs( } return undefined; } - return resolveInputDataContainer( - input.extensionData, - attachedNodes[0], - inputName, - collector, - ); + try { + return resolveInputDataContainer( + input.extensionData, + attachedNodes[0], + inputName, + collector, + ); + } catch (error) { + if (error === INSTANTIATION_FAILED) { + if (input.config.optional) { + return undefined; + } + collector.report({ + code: 'EXTENSION_ATTACHMENT_MISSING', + message: `input '${inputName}' is required but it failed to be instantiated`, + }); + } + throw error; + } } return mapWithFailures(attachedNodes, attachment =>