From aded5d1ce4a47ed9284e2559f41c6dff27270343 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 18 Oct 2023 12:29:32 +0200 Subject: [PATCH] frontend-app-api: migrate createExtensionInstance tests Signed-off-by: Patrik Oldsberg --- .../graph/createExtensionInstance.test.ts | 456 ------------------ .../graph/instantiateAppNodeTree.test.ts | 441 +++++++++++++++++ 2 files changed, 441 insertions(+), 456 deletions(-) delete mode 100644 packages/frontend-app-api/src/wiring/graph/createExtensionInstance.test.ts create mode 100644 packages/frontend-app-api/src/wiring/graph/instantiateAppNodeTree.test.ts diff --git a/packages/frontend-app-api/src/wiring/graph/createExtensionInstance.test.ts b/packages/frontend-app-api/src/wiring/graph/createExtensionInstance.test.ts deleted file mode 100644 index 6e69e99cb7..0000000000 --- a/packages/frontend-app-api/src/wiring/graph/createExtensionInstance.test.ts +++ /dev/null @@ -1,456 +0,0 @@ -/* - * Copyright 2023 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 { - createExtension, - createExtensionDataRef, - createExtensionInput, - createSchemaFromZod, -} from '@backstage/frontend-plugin-api'; -import { createExtensionInstance } from './createExtensionInstance'; - -const testDataRef = createExtensionDataRef('test'); -const otherDataRef = createExtensionDataRef('other'); -const inputMirrorDataRef = createExtensionDataRef('mirror'); - -const simpleExtension = createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - output: { - test: testDataRef, - other: otherDataRef.optional(), - }, - configSchema: createSchemaFromZod(z => - z.object({ - output: z.string().default('test'), - other: z.number().optional(), - }), - ), - factory({ bind, config }) { - bind({ test: config.output, other: config.other }); - }, -}); - -describe('createExtensionInstance', () => { - it('should create a simple extension instance', () => { - const attachments = new Map(); - const instance = createExtensionInstance({ - attachments, - config: undefined, - extension: simpleExtension, - }); - - expect(instance.id).toBe('core.test'); - expect(instance.attachments).toBe(attachments); - expect(instance.getData(testDataRef)).toEqual('test'); - }); - - it('should create an extension with different kind of inputs', () => { - const attachments = new Map([ - [ - 'optionalSingletonPresent', - [ - createExtensionInstance({ - attachments: new Map(), - config: { output: 'optionalSingletonPresent' }, - extension: simpleExtension, - }), - ], - ], - [ - 'singleton', - [ - createExtensionInstance({ - attachments: new Map(), - config: { output: 'singleton', other: 2 }, - extension: simpleExtension, - }), - ], - ], - [ - 'many', - [ - createExtensionInstance({ - attachments: new Map(), - config: { output: 'many1' }, - extension: simpleExtension, - }), - createExtensionInstance({ - attachments: new Map(), - config: { output: 'many2', other: 3 }, - extension: simpleExtension, - }), - ], - ], - ]); - const instance = createExtensionInstance({ - attachments, - config: undefined, - extension: createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - optionalSingletonPresent: createExtensionInput( - { - test: testDataRef, - other: otherDataRef.optional(), - }, - { singleton: true, optional: true }, - ), - optionalSingletonMissing: createExtensionInput( - { - test: testDataRef, - other: otherDataRef.optional(), - }, - { singleton: true, optional: true }, - ), - singleton: createExtensionInput( - { - test: testDataRef, - other: otherDataRef.optional(), - }, - { singleton: true }, - ), - many: createExtensionInput({ - test: testDataRef, - other: otherDataRef.optional(), - }), - }, - output: { - inputMirror: inputMirrorDataRef, - }, - factory({ bind, inputs }) { - bind({ inputMirror: inputs }); - }, - }), - }); - - expect(instance.id).toBe('core.test'); - expect(instance.attachments).toBe(attachments); - expect(instance.getData(inputMirrorDataRef)).toEqual({ - optionalSingletonPresent: { test: 'optionalSingletonPresent' }, - singleton: { test: 'singleton', other: 2 }, - many: [{ test: 'many1' }, { test: 'many2', other: 3 }], - }); - }); - - it('should refuse to create an extension with invalid config', () => { - expect(() => - createExtensionInstance({ - attachments: new Map(), - config: { other: 'not-a-number' }, - extension: simpleExtension, - }), - ).toThrow( - "Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'", - ); - }); - - it('should forward extension factory errors', () => { - expect(() => - createExtensionInstance({ - attachments: new Map(), - config: { other: 'not-a-number' }, - extension: createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - output: {}, - factory() { - const error = new Error('NOPE'); - error.name = 'NopeError'; - throw error; - }, - }), - }), - ).toThrow( - "Failed to instantiate extension 'core.test'; caused by NopeError: NOPE", - ); - }); - - it('should refuse to create an instance with duplicate output', () => { - const attachments = new Map(); - expect(() => - createExtensionInstance({ - attachments, - config: undefined, - extension: createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - output: { - test1: testDataRef, - test2: testDataRef, - }, - factory({ bind }) { - bind({ test1: 'test', test2: 'test2' }); - }, - }), - }), - ).toThrow( - "Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'", - ); - }); - - it('should refuse to create an instance with disconnected output data', () => { - const attachments = new Map(); - expect(() => - createExtensionInstance({ - attachments, - config: undefined, - extension: createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - output: { - test: testDataRef, - }, - factory({ bind }) { - bind({ nonexistent: 'test' } as any); - }, - }), - }), - ).toThrow( - "Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'", - ); - }); - - it('should refuse to create an instance with missing required input', () => { - expect(() => - createExtensionInstance({ - attachments: new Map(), - config: undefined, - extension: createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput( - { - test: testDataRef, - }, - { singleton: true }, - ), - }, - output: {}, - factory() {}, - }), - }), - ).toThrow( - "Failed to instantiate extension 'core.test', input 'singleton' is required but was not received", - ); - }); - - 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({ - attachments: new Map([ - [ - 'singleton', - [ - createExtensionInstance({ - attachments: new Map(), - config: { output: 'many1' }, - extension: simpleExtension, - }), - createExtensionInstance({ - attachments: new Map(), - config: { output: 'many2' }, - extension: simpleExtension, - }), - ], - ], - ]), - config: undefined, - extension: createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput( - { - test: testDataRef, - }, - { singleton: true }, - ), - }, - output: {}, - factory() {}, - }), - }), - ).toThrow( - "Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'", - ); - }); - - it('should refuse to create an instance with multiple inputs for optional singleton', () => { - expect(() => - createExtensionInstance({ - attachments: new Map([ - [ - 'singleton', - [ - createExtensionInstance({ - attachments: new Map(), - config: { output: 'many1' }, - extension: simpleExtension, - }), - createExtensionInstance({ - attachments: new Map(), - config: { output: 'many2' }, - extension: simpleExtension, - }), - ], - ], - ]), - config: undefined, - extension: createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput( - { - test: testDataRef, - }, - { singleton: true, optional: true }, - ), - }, - output: {}, - factory() {}, - }), - }), - ).toThrow( - "Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'", - ); - }); - - it('should refuse to create an instance with multiple inputs that did not provide required data', () => { - expect(() => - createExtensionInstance({ - attachments: new Map([ - [ - 'singleton', - [ - createExtensionInstance({ - attachments: new Map(), - config: undefined, - extension: simpleExtension, - }), - ], - ], - ]), - config: undefined, - extension: createExtension({ - id: 'core.test', - attachTo: { id: 'ignored', input: 'ignored' }, - inputs: { - singleton: createExtensionInput( - { - other: otherDataRef, - }, - { singleton: true }, - ), - }, - output: {}, - factory() {}, - }), - }), - ).toThrow( - "Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'", - ); - }); -}); diff --git a/packages/frontend-app-api/src/wiring/graph/instantiateAppNodeTree.test.ts b/packages/frontend-app-api/src/wiring/graph/instantiateAppNodeTree.test.ts new file mode 100644 index 0000000000..ddd842c6c0 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/graph/instantiateAppNodeTree.test.ts @@ -0,0 +1,441 @@ +/* + * Copyright 2023 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 { + Extension, + createExtension, + createExtensionDataRef, + createExtensionInput, + createSchemaFromZod, +} from '@backstage/frontend-plugin-api'; +import { createAppNodeInstance } from './instantiateAppNodeTree'; +import { AppNodeInstance, AppNodeSpec } from './types'; + +const testDataRef = createExtensionDataRef('test'); +const otherDataRef = createExtensionDataRef('other'); +const inputMirrorDataRef = createExtensionDataRef('mirror'); + +const simpleExtension = createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { + test: testDataRef, + other: otherDataRef.optional(), + }, + configSchema: createSchemaFromZod(z => + z.object({ + output: z.string().default('test'), + other: z.number().optional(), + }), + ), + factory({ bind, config }) { + bind({ test: config.output, other: config.other }); + }, +}); + +function makeSpec( + extension: Extension, + config?: TConfig, +): AppNodeSpec { + return { + id: extension.id, + attachTo: extension.attachTo, + disabled: extension.disabled, + extension, + config, + source: undefined, + }; +} + +function makeInstanceWithId( + extension: Extension, + config?: TConfig, +): { id: string; instance: AppNodeInstance } { + return { + id: extension.id, + instance: createAppNodeInstance({ + spec: makeSpec(extension, config), + attachments: new Map(), + }), + }; +} + +describe('createAppNodeInstance', () => { + it('should create a simple extension instance', () => { + const attachments = new Map(); + const instance = createAppNodeInstance({ + spec: makeSpec(simpleExtension), + attachments, + }); + + expect(Array.from(instance.getDataRefs())).toEqual([ + testDataRef, + otherDataRef.optional(), + ]); + expect(instance.getData(testDataRef)).toEqual('test'); + }); + + it('should create an extension with different kind of inputs', () => { + const attachments = new Map([ + [ + 'optionalSingletonPresent', + [ + makeInstanceWithId(simpleExtension, { + output: 'optionalSingletonPresent', + }), + ], + ], + [ + 'singleton', + [ + makeInstanceWithId(simpleExtension, { + output: 'singleton', + other: 2, + }), + ], + ], + [ + 'many', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many2', other: 3 }), + ], + ], + ]); + const instance = createAppNodeInstance({ + attachments, + spec: makeSpec( + createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + optionalSingletonPresent: createExtensionInput( + { + test: testDataRef, + other: otherDataRef.optional(), + }, + { singleton: true, optional: true }, + ), + optionalSingletonMissing: createExtensionInput( + { + test: testDataRef, + other: otherDataRef.optional(), + }, + { singleton: true, optional: true }, + ), + singleton: createExtensionInput( + { + test: testDataRef, + other: otherDataRef.optional(), + }, + { singleton: true }, + ), + many: createExtensionInput({ + test: testDataRef, + other: otherDataRef.optional(), + }), + }, + output: { + inputMirror: inputMirrorDataRef, + }, + factory({ bind, inputs }) { + bind({ inputMirror: inputs }); + }, + }), + ), + }); + + expect(Array.from(instance.getDataRefs())).toEqual([inputMirrorDataRef]); + expect(instance.getData(inputMirrorDataRef)).toEqual({ + optionalSingletonPresent: { test: 'optionalSingletonPresent' }, + singleton: { test: 'singleton', other: 2 }, + many: [{ test: 'many1' }, { test: 'many2', other: 3 }], + }); + }); + + it('should refuse to create an extension with invalid config', () => { + expect(() => + createAppNodeInstance({ + spec: { + ...makeSpec(simpleExtension), + config: { other: 'not-a-number' }, + }, + attachments: new Map(), + }), + ).toThrow( + "Invalid configuration for extension 'core.test'; caused by Error: Expected number, received string at 'other'", + ); + }); + + it('should forward extension factory errors', () => { + expect(() => + createAppNodeInstance({ + spec: makeSpec( + createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: {}, + factory() { + const error = new Error('NOPE'); + error.name = 'NopeError'; + throw error; + }, + }), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'core.test'; caused by NopeError: NOPE", + ); + }); + + it('should refuse to create an instance with duplicate output', () => { + expect(() => + createAppNodeInstance({ + spec: makeSpec( + createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { + test1: testDataRef, + test2: testDataRef, + }, + factory({ bind }) { + bind({ test1: 'test', test2: 'test2' }); + }, + }), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'core.test', duplicate extension data 'test' received via output 'test2'", + ); + }); + + it('should refuse to create an instance with disconnected output data', () => { + expect(() => + createAppNodeInstance({ + spec: makeSpec( + createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + output: { + test: testDataRef, + }, + factory({ bind }) { + bind({ nonexistent: 'test' } as any); + }, + }), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'core.test', unknown output provided via 'nonexistent'", + ); + }); + + it('should refuse to create an instance with missing required input', () => { + expect(() => + createAppNodeInstance({ + spec: makeSpec( + createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput( + { + test: testDataRef, + }, + { singleton: true }, + ), + }, + output: {}, + factory() {}, + }), + ), + attachments: new Map(), + }), + ).toThrow( + "Failed to instantiate extension 'core.test', input 'singleton' is required but was not received", + ); + }); + + it('should refuse to create an instance with undeclared inputs', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + [ + 'declared', + [ + makeInstanceWithId(simpleExtension, { + output: 'many1', + }), + ], + ], + [ + 'undeclared', + [ + makeInstanceWithId(simpleExtension, { + output: 'many1', + }), + ], + ], + ]), + spec: makeSpec( + 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(() => + createAppNodeInstance({ + attachments: new Map([ + [ + 'undeclared1', + [makeInstanceWithId(simpleExtension, { output: 'many1' })], + ], + [ + 'undeclared2', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many1' }), + ], + ], + ]), + spec: makeSpec( + 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(() => + createAppNodeInstance({ + attachments: new Map([ + [ + 'singleton', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many2' }), + ], + ], + ]), + spec: makeSpec( + createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput( + { + test: testDataRef, + }, + { singleton: true }, + ), + }, + output: {}, + factory() {}, + }), + ), + }), + ).toThrow( + "Failed to instantiate extension 'core.test', expected exactly one 'singleton' input but received multiple: 'core.test', 'core.test'", + ); + }); + + it('should refuse to create an instance with multiple inputs for optional singleton', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + [ + 'singleton', + [ + makeInstanceWithId(simpleExtension, { output: 'many1' }), + makeInstanceWithId(simpleExtension, { output: 'many2' }), + ], + ], + ]), + spec: makeSpec( + createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput( + { + test: testDataRef, + }, + { singleton: true, optional: true }, + ), + }, + output: {}, + factory() {}, + }), + ), + }), + ).toThrow( + "Failed to instantiate extension 'core.test', expected at most one 'singleton' input but received multiple: 'core.test', 'core.test'", + ); + }); + + it('should refuse to create an instance with multiple inputs that did not provide required data', () => { + expect(() => + createAppNodeInstance({ + attachments: new Map([ + ['singleton', [makeInstanceWithId(simpleExtension, undefined)]], + ]), + spec: makeSpec( + createExtension({ + id: 'core.test', + attachTo: { id: 'ignored', input: 'ignored' }, + inputs: { + singleton: createExtensionInput( + { + other: otherDataRef, + }, + { singleton: true }, + ), + }, + output: {}, + factory() {}, + }), + ), + }), + ).toThrow( + "Failed to instantiate extension 'core.test', input 'singleton' did not receive required extension data 'other' from extension 'core.test'", + ); + }); +});