feat: implementing some test frameworks for actions
Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
@@ -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(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user