From dbd64a283f5a186901f0c718c929723518a2f5b8 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Fri, 13 Jun 2025 13:18:50 +0200 Subject: [PATCH] feat: implementing some test frameworks for actions Signed-off-by: benjdlambert --- packages/backend-test-utils/package.json | 4 +- .../next/services/MockActionsRegistry.test.ts | 232 ++++++++++++++++++ .../src/next/services/MockActionsRegistry.ts | 131 ++++++++++ .../src/next/services/mockServices.ts | 9 + .../src/next/wiring/TestBackend.ts | 2 + yarn.lock | 2 + 6 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 packages/backend-test-utils/src/next/services/MockActionsRegistry.test.ts create mode 100644 packages/backend-test-utils/src/next/services/MockActionsRegistry.ts diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index d58720723d..cc47249a2d 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -73,7 +73,9 @@ "testcontainers": "^10.0.0", "textextensions": "^5.16.0", "uuid": "^11.0.0", - "yn": "^4.0.0" + "yn": "^4.0.0", + "zod": "^3.22.4", + "zod-to-json-schema": "^3.20.4" }, "devDependencies": { "@backstage/cli": "workspace:^", diff --git a/packages/backend-test-utils/src/next/services/MockActionsRegistry.test.ts b/packages/backend-test-utils/src/next/services/MockActionsRegistry.test.ts new file mode 100644 index 0000000000..4b13be921a --- /dev/null +++ b/packages/backend-test-utils/src/next/services/MockActionsRegistry.test.ts @@ -0,0 +1,232 @@ +/* + * 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 { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { startTestBackend } from '../wiring'; +import { mockServices } from './mockServices'; +import { mockCredentials } from './mockCredentials'; +import { Router } from 'express'; +import supertest from 'supertest'; + +describe('MockActionsRegistry', () => { + it('should be able to register and invoke actions', async () => { + const registry = mockServices.actionsRegistry(); + + registry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => + z.object({ + name: z.string(), + }), + output: z => + z.object({ + name: z.string(), + }), + }, + action: async ({ input }) => ({ output: { name: input.name } }), + }); + + const result = await registry.invoke({ + id: 'testing:test', + input: { name: 'test' }, + }); + + expect(result).toEqual({ output: { name: 'test' } }); + }); + + it('should throw an error when the input is invalid to the action', async () => { + const registry = mockServices.actionsRegistry(); + + registry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => z.object({ name: z.string() }), + output: z => z.object({ name: z.string() }), + }, + action: async ({ input }) => ({ output: { name: input.name } }), + }); + + await expect( + registry.invoke({ id: 'testing:test', input: { name: 1 } }), + ).rejects.toThrow('Invalid input to action "testing:test"'); + }); + + it('should throw an error when the action is not found', async () => { + const registry = mockServices.actionsRegistry(); + + await expect(registry.invoke({ id: 'test' })).rejects.toThrow( + 'Action "test" not found, available actions: none', + ); + }); + + it('should throw an error when the action is not found with recommended actions', async () => { + const registry = mockServices.actionsRegistry(); + + registry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => z.object({ name: z.string() }), + output: z => z.object({ name: z.string() }), + }, + action: async ({ input }) => ({ output: { name: input.name } }), + }); + + await expect(registry.invoke({ id: 'test' })).rejects.toThrow( + 'Action "test" not found, available actions: "testing:test"', + ); + }); + + it('should throw an error when the output is invalid', async () => { + const registry = mockServices.actionsRegistry(); + + registry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => z.object({ name: z.number() }), + output: z => z.object({ name: z.string() }), + }, + // @ts-expect-error - we want to test the error case + action: async ({ input }) => ({ output: { name: input.name } }), + }); + + await expect( + registry.invoke({ id: 'testing:test', input: { name: 1 } }), + ).rejects.toThrow('Invalid output from action "testing:test"'); + }); + + it('should list the actions correctly', async () => { + const registry = mockServices.actionsRegistry(); + + registry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => z.object({ name: z.string() }), + output: z => z.object({ name: z.string() }), + }, + action: async ({ input }) => ({ output: { name: input.name } }), + }); + + const result = await registry.list(); + + expect(result).toMatchObject({ + actions: [ + { + id: 'testing:test', + name: 'test', + title: 'Test', + description: 'Test', + attributes: { + destructive: false, + idempotent: false, + readOnly: false, + }, + schema: { + input: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + output: { + type: 'object', + properties: { + name: { type: 'string' }, + }, + }, + }, + }, + ], + }); + }); + + describe('mockServices.actions + mockService.actionsRegistry', () => { + it('should be able to register and invoke actions', async () => { + const pluginWithAction = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { actionsRegistry: coreServices.actionsRegistry }, + async init({ actionsRegistry }) { + actionsRegistry.register({ + name: 'test', + title: 'Test', + description: 'Test', + schema: { + input: z => z.object({ name: z.string() }), + output: z => z.object({ name: z.string() }), + }, + action: async ({ input }) => { + expect(input).toEqual({ name: 'test' }); + return { output: { name: input.name } }; + }, + }); + }, + }); + }, + }); + + const pluginToCallAction = createBackendPlugin({ + pluginId: 'my-plugin-to-call-action', + register(reg) { + reg.registerInit({ + deps: { + actions: coreServices.actions, + router: coreServices.httpRouter, + }, + async init({ actions, router }) { + const testRouter = Router(); + router.use(testRouter); + + testRouter.post('/test', async (_, res) => { + const { output } = await actions.invoke({ + id: 'my-plugin:test', + input: { name: 'test' }, + credentials: mockCredentials.service(), + }); + + res.json(output); + }); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginWithAction, pluginToCallAction], + }); + + const { body, status } = await supertest(server).post( + '/api/my-plugin-to-call-action/test', + ); + + expect(status).toBe(200); + expect(body).toEqual({ name: 'test' }); + }); + }); +}); diff --git a/packages/backend-test-utils/src/next/services/MockActionsRegistry.ts b/packages/backend-test-utils/src/next/services/MockActionsRegistry.ts new file mode 100644 index 0000000000..72f70e5f2b --- /dev/null +++ b/packages/backend-test-utils/src/next/services/MockActionsRegistry.ts @@ -0,0 +1,131 @@ +/* + * 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 { + ActionsRegistryActionOptions, + ActionsRegistryService, + ActionsService, + ActionsServiceAction, + BackstageCredentials, + LoggerService, +} from '@backstage/backend-plugin-api'; +import { ForwardedError, InputError, NotFoundError } from '@backstage/errors'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { z, AnyZodObject } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; +import { mockCredentials } from './mockCredentials'; + +export class MockActionsRegistry + implements ActionsRegistryService, ActionsService +{ + private constructor(private readonly logger: LoggerService) {} + + static create(opts: { logger: LoggerService }) { + return new MockActionsRegistry(opts.logger); + } + + readonly actions: Map> = + new Map(); + + async list(): Promise<{ actions: ActionsServiceAction[] }> { + return { + actions: Array.from(this.actions.entries()).map(([id, action]) => ({ + id, + name: action.name, + title: action.title, + description: action.description, + attributes: { + // todo(blam): what's safe defaults? + destructive: action.attributes?.destructive ?? false, + idempotent: action.attributes?.idempotent ?? false, + readOnly: action.attributes?.readOnly ?? false, + }, + schema: { + input: action.schema?.input + ? zodToJsonSchema(action.schema.input(z)) + : zodToJsonSchema(z.object({})), + output: action.schema?.output + ? zodToJsonSchema(action.schema.output(z)) + : zodToJsonSchema(z.object({})), + } as ActionsServiceAction['schema'], + })), + }; + } + + async invoke(opts: { + id: string; + input?: JsonObject; + credentials?: BackstageCredentials; + }): Promise<{ output: JsonValue }> { + const action = this.actions.get(opts.id); + + if (!action) { + const availableActionIds = Array.from(this.actions.keys()).join(', '); + throw new NotFoundError( + `Action "${opts.id}" not found, available actions: ${ + availableActionIds ? `"${availableActionIds}"` : 'none' + }`, + ); + } + + const input = action.schema?.input + ? action.schema.input(z).safeParse(opts.input) + : ({ success: true, data: undefined } as const); + + if (!input.success) { + throw new InputError(`Invalid input to action "${opts.id}"`, input.error); + } + + try { + const result = await action.action({ + input: input.data, + credentials: opts.credentials ?? mockCredentials.none(), + logger: this.logger, + }); + + const output = action.schema?.output + ? action.schema.output(z).safeParse(result?.output) + : ({ success: true, data: result?.output } as const); + + if (!output.success) { + throw new InputError( + `Invalid output from action "${opts.id}"`, + output.error, + ); + } + + return { output: output.data }; + } catch (error) { + throw new ForwardedError( + `Failed execution of action "${opts.id}"`, + error, + ); + } + } + + register< + TInputSchema extends AnyZodObject, + TOutputSchema extends AnyZodObject, + >(options: ActionsRegistryActionOptions): void { + // hardcode testing: prefix similar to how the default actions registry does it + const id = `testing:${options.name}`; + + if (this.actions.has(id)) { + throw new Error(`Action with id "${id}" is already registered`); + } + + this.actions.set(id, options); + } +} diff --git a/packages/backend-test-utils/src/next/services/mockServices.ts b/packages/backend-test-utils/src/next/services/mockServices.ts index 95f8b084c9..a44bf912e7 100644 --- a/packages/backend-test-utils/src/next/services/mockServices.ts +++ b/packages/backend-test-utils/src/next/services/mockServices.ts @@ -58,6 +58,7 @@ import { MockEventsService } from './MockEventsService'; import { actionsServiceFactory } from '@backstage/backend-defaults/actions'; import { actionsRegistryServiceFactory } from '@backstage/backend-defaults/actionsRegistry'; import { MockPermissionsService } from './MockPermissionsService'; +import { MockActionsRegistry } from './MockActionsRegistry'; /** @internal */ function createLoggerMock() { @@ -560,6 +561,14 @@ export namespace mockServices { })); } + export function actionsRegistry(options?: { + logger: LoggerService; + }): MockActionsRegistry { + return MockActionsRegistry.create({ + logger: options?.logger ?? mockServices.logger.mock(), + }); + } + export namespace actionsRegistry { export const factory = () => actionsRegistryServiceFactory; export const mock = simpleMock(coreServices.actionsRegistry, () => ({ diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 20d02cf880..84042aedd8 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -84,6 +84,8 @@ export const defaultServiceFactories = [ mockServices.userInfo.factory(), mockServices.urlReader.factory(), mockServices.events.factory(), + mockServices.actionsRegistry.factory(), + mockServices.actions.factory(), ]; /** diff --git a/yarn.lock b/yarn.lock index 832e71e5cd..0b5ac6270d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3801,6 +3801,8 @@ __metadata: textextensions: "npm:^5.16.0" uuid: "npm:^11.0.0" yn: "npm:^4.0.0" + zod: "npm:^3.22.4" + zod-to-json-schema: "npm:^3.20.4" languageName: unknown linkType: soft