feat: implementing some test frameworks for actions

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2025-06-13 13:18:50 +02:00
parent c94f8e089c
commit dbd64a283f
6 changed files with 379 additions and 1 deletions
+3 -1
View File
@@ -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:^",
@@ -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' });
});
});
});
@@ -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<string, ActionsRegistryActionOptions<any, any>> =
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<TInputSchema, TOutputSchema>): 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);
}
}
@@ -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, () => ({
@@ -84,6 +84,8 @@ export const defaultServiceFactories = [
mockServices.userInfo.factory(),
mockServices.urlReader.factory(),
mockServices.events.factory(),
mockServices.actionsRegistry.factory(),
mockServices.actions.factory(),
];
/**
+2
View File
@@ -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