diff --git a/.changeset/five-hornets-provide.md b/.changeset/five-hornets-provide.md new file mode 100644 index 0000000000..a422c5fbc8 --- /dev/null +++ b/.changeset/five-hornets-provide.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +The app will now reject any extensions that attach to nonexistent inputs. diff --git a/packages/frontend-app-api/src/extensions/Core.tsx b/packages/frontend-app-api/src/extensions/Core.tsx index 4a66afe38d..ec00059bf8 100644 --- a/packages/frontend-app-api/src/extensions/Core.tsx +++ b/packages/frontend-app-api/src/extensions/Core.tsx @@ -27,6 +27,9 @@ export const Core = createExtension({ apis: createExtensionInput({ api: coreExtensionData.apiFactory, }), + themes: createExtensionInput({ + theme: coreExtensionData.theme, + }), }, output: {}, factory() {}, diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts index a510adf1b7..b833bda1b5 100644 --- a/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts +++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromInstanceTree.test.ts @@ -48,7 +48,7 @@ function createTestExtension(options: { id: options.id, attachTo: options.parent ? { id: options.parent, input: 'children' } - : { id: 'core.routes', input: 'children' }, + : { id: 'core.routes', input: 'routes' }, output: { element: coreExtensionData.reactElement, path: coreExtensionData.routePath.optional(), @@ -305,6 +305,7 @@ describe('discovery', () => { }), createTestExtension({ id: 'fooEmpty', + parent: 'foo', }), createTestExtension({ id: 'page3', diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx index abd0fe9b00..600901f96e 100644 --- a/packages/frontend-app-api/src/wiring/createApp.test.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx @@ -16,6 +16,7 @@ import { createExtension, + createExtensionInput, createPageExtension, createPlugin, createThemeExtension, @@ -143,6 +144,9 @@ describe('createApp', () => { extension: createExtension({ id: 'root', attachTo: { id: '', input: '' }, + inputs: { + children: createExtensionInput({}), + }, output: {}, factory() {}, }), @@ -181,6 +185,9 @@ describe('createApp', () => { extension: createExtension({ id: 'root', attachTo: { id: '', input: '' }, + inputs: { + children: createExtensionInput({}), + }, output: {}, factory() {}, }), diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.test.ts b/packages/frontend-app-api/src/wiring/createExtensionInstance.test.ts index 6550d76608..6e69e99cb7 100644 --- a/packages/frontend-app-api/src/wiring/createExtensionInstance.test.ts +++ b/packages/frontend-app-api/src/wiring/createExtensionInstance.test.ts @@ -250,6 +250,92 @@ describe('createExtensionInstance', () => { ); }); + it('should refuse to create an instance with undeclared inputs', () => { + expect(() => + createExtensionInstance({ + attachments: new Map([ + [ + 'declared', + [ + createExtensionInstance({ + attachments: new Map(), + config: { output: 'many1' }, + extension: simpleExtension, + }), + ], + ], + [ + 'undeclared', + [ + createExtensionInstance({ + attachments: new Map(), + config: { output: 'many1' }, + extension: simpleExtension, + }), + ], + ], + ]), + config: undefined, + extension: createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + declared: createExtensionInput({ + test: testDataRef, + }), + }, + output: {}, + factory() {}, + }), + }), + ).toThrow( + "Failed to instantiate extension 'core.test', received undeclared input 'undeclared' from extension 'core.test'", + ); + }); + + it('should refuse to create an instance with multiple undeclared inputs', () => { + expect(() => + createExtensionInstance({ + attachments: new Map([ + [ + 'undeclared1', + [ + createExtensionInstance({ + attachments: new Map(), + config: { output: 'many1' }, + extension: simpleExtension, + }), + ], + ], + [ + 'undeclared2', + [ + createExtensionInstance({ + attachments: new Map(), + config: { output: 'many1' }, + extension: simpleExtension, + }), + createExtensionInstance({ + attachments: new Map(), + config: { output: 'many1' }, + extension: simpleExtension, + }), + ], + ], + ]), + config: undefined, + extension: createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: {}, + factory() {}, + }), + }), + ).toThrow( + "Failed to instantiate extension 'core.test', received undeclared inputs 'undeclared1' from extension 'core.test' and 'undeclared2' from extensions 'core.test', 'core.test'", + ); + }); + it('should refuse to create an instance with multiple inputs for required singleton', () => { expect(() => createExtensionInstance({ diff --git a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts index 9aec3078bc..5a325dc0a0 100644 --- a/packages/frontend-app-api/src/wiring/createExtensionInstance.ts +++ b/packages/frontend-app-api/src/wiring/createExtensionInstance.ts @@ -60,6 +60,24 @@ function resolveInputs( inputMap: AnyExtensionInputMap, attachments: Map, ) { + const undeclaredAttachments = Array.from(attachments.entries()).filter( + ([inputName]) => inputMap[inputName] === undefined, + ); + if (undeclaredAttachments.length > 0) { + throw new Error( + `received undeclared input${ + undeclaredAttachments.length > 1 ? 's' : '' + } ${undeclaredAttachments + .map( + ([k, exts]) => + `'${k}' from extension${exts.length > 1 ? 's' : ''} '${exts + .map(e => e.id) + .join("', '")}'`, + ) + .join(' and ')}`, + ); + } + return mapValues(inputMap, (input, inputName) => { const attachedInstances = attachments.get(inputName) ?? []; if (input.config.singleton) {