diff --git a/packages/server/src/api/controllers/automation.ts b/packages/server/src/api/controllers/automation.ts index abc0e492c0..a77014cf31 100644 --- a/packages/server/src/api/controllers/automation.ts +++ b/packages/server/src/api/controllers/automation.ts @@ -2,7 +2,7 @@ import * as triggers from "../../automations/triggers" import { sdk as coreSdk } from "@budibase/shared-core" import { DocumentType } from "../../db/utils" import { updateTestHistory, removeDeprecated } from "../../automations/utils" -import { setTestFlag, clearTestFlag } from "../../utilities/redis" +import { withTestFlag } from "../../utilities/redis" import { context, cache, events, db as dbCore } from "@budibase/backend-core" import { automations, features } from "@budibase/pro" import { @@ -28,11 +28,18 @@ import { TriggerAutomationResponse, TestAutomationRequest, TestAutomationResponse, + TestAutomationStepRequest, + TestAutomationStepResponse, } from "@budibase/types" -import { getActionDefinitions as actionDefs } from "../../automations/actions" +import { + getActionDefinitions as actionDefs, + getAction, +} from "../../automations/actions" import sdk from "../../sdk" import { builderSocket } from "../../websockets" import env from "../../environment" +import { NoopEmitter } from "../../events" +import { enrichBaseContext } from "../../threads/automation" async function getActionDefinitions() { return removeDeprecated(await actionDefs()) @@ -231,24 +238,68 @@ export async function test( ctx: UserCtx ) { const db = context.getAppDB() - let automation = await db.get(ctx.params.id) - await setTestFlag(automation._id!) - const testInput = prepareTestInput(ctx.request.body) - const response = await triggers.externalTrigger( - automation, - { - ...testInput, - appId: ctx.appId, - user: sdk.users.getUserContextBindings(ctx.user), - }, - { getResponses: true } - ) - // save a test history run - await updateTestHistory(ctx.appId, automation, { - ...ctx.request.body, - occurredAt: new Date().getTime(), + const automation = await db.tryGet(ctx.params.id) + if (!automation) { + ctx.throw(404, `Automation ${ctx.params.id} not found`) + } + + const { request, appId } = ctx + const { body } = request + + ctx.body = await withTestFlag(automation._id!, async () => { + const occurredAt = new Date().getTime() + await updateTestHistory(appId, automation, { ...body, occurredAt }) + + const user = sdk.users.getUserContextBindings(ctx.user) + return await triggers.externalTrigger( + automation, + { ...prepareTestInput(body), appId, user }, + { getResponses: true } + ) }) - await clearTestFlag(automation._id!) - ctx.body = response + await events.automation.tested(automation) } + +export async function testStep( + ctx: UserCtx +) { + const { id, stepId } = ctx.params + const db = context.getAppDB() + const automation = await db.tryGet(id) + if (!automation) { + ctx.throw(404, `Automation ${ctx.params.id} not found`) + } + + const step = automation.definition.steps.find(s => s.stepId === stepId) + if (!step) { + ctx.throw(404, `Step ${stepId} not found on automation ${id}`) + } + + if (step.stepId === AutomationActionStepId.BRANCH) { + ctx.throw(400, "Branch steps cannot be tested directly") + } + if (step.stepId === AutomationActionStepId.LOOP) { + ctx.throw(400, "Loop steps cannot be tested directly") + } + + const { body } = ctx.request + + const fn = await getAction(step.stepId) + if (!fn) { + ctx.throw(400, `Step ${stepId} is not a valid step`) + } + + const output = await withTestFlag( + automation._id!, + async () => + await fn({ + inputs: body.inputs, + context: await enrichBaseContext(body.context), + appId: ctx.appId, + emitter: new NoopEmitter(), + }) + ) + + ctx.body = output +} diff --git a/packages/server/src/api/routes/automation.ts b/packages/server/src/api/routes/automation.ts index 489487271c..ea905be0cd 100644 --- a/packages/server/src/api/routes/automation.ts +++ b/packages/server/src/api/routes/automation.ts @@ -1,6 +1,6 @@ import Router from "@koa/router" import * as controller from "../controllers/automation" -import authorized from "../../middleware/authorized" +import authorized, { authorizedResource } from "../../middleware/authorized" import { permissions } from "@budibase/backend-core" import { bodyResource, paramResource } from "../../middleware/resourceId" import { @@ -82,5 +82,15 @@ router ), controller.test ) + .post( + "/api/automations/:id/step/:stepId/test", + appInfoMiddleware({ appType: AppType.DEV }), + authorizedResource( + permissions.PermissionType.AUTOMATION, + permissions.PermissionLevel.EXECUTE, + "id" + ), + controller.testStep + ) export default router diff --git a/packages/server/src/events/NoopEmitter.ts b/packages/server/src/events/NoopEmitter.ts new file mode 100644 index 0000000000..ed87618ead --- /dev/null +++ b/packages/server/src/events/NoopEmitter.ts @@ -0,0 +1,39 @@ +import { EventEmitter } from "events" +import { + Table, + Row, + ContextEmitter, + EventType, + UserBindings, +} from "@budibase/types" + +export class NoopEmitter extends EventEmitter implements ContextEmitter { + emitRow(values: { + eventName: EventType.ROW_SAVE + appId: string + row: Row + table: Table + user: UserBindings + }): void + emitRow(values: { + eventName: EventType.ROW_UPDATE + appId: string + row: Row + table: Table + oldRow: Row + user: UserBindings + }): void + emitRow(values: { + eventName: EventType.ROW_DELETE + appId: string + row: Row + user: UserBindings + }): void + emitRow(_values: unknown): void { + return + } + + emitTable(_eventName: string, _appId: string, _table?: Table) { + return + } +} diff --git a/packages/server/src/events/index.ts b/packages/server/src/events/index.ts index 23c3f3e512..90bf932bcf 100644 --- a/packages/server/src/events/index.ts +++ b/packages/server/src/events/index.ts @@ -2,5 +2,6 @@ import BudibaseEmitter from "./BudibaseEmitter" const emitter = new BudibaseEmitter() +export { NoopEmitter } from "./NoopEmitter" export { init } from "./docUpdates" export default emitter diff --git a/packages/server/src/threads/automation.ts b/packages/server/src/threads/automation.ts index 2d10f5d1fb..2790d8fda6 100644 --- a/packages/server/src/threads/automation.ts +++ b/packages/server/src/threads/automation.ts @@ -29,6 +29,7 @@ import { LoopStep, UserBindings, isBasicSearchOperator, + ContextEmitter, } from "@budibase/types" import { AutomationContext, @@ -71,6 +72,24 @@ function getLoopIterations(loopStep: LoopStep) { return 0 } +export async function enrichBaseContext(context: Record) { + context.env = await sdkUtils.getEnvironmentVariables() + + try { + const { config } = await configs.getSettingsConfigDoc() + context.settings = { + url: config.platformUrl, + logo: config.logoUrl, + company: config.company, + } + } catch (e) { + // if settings doc doesn't exist, make the settings blank + context.settings = {} + } + + return context +} + /** * The automation orchestrator is a class responsible for executing automations. * It handles the context of the automation and makes sure each step gets the correct @@ -80,7 +99,7 @@ class Orchestrator { private chainCount: number private appId: string private automation: Automation - private emitter: any + private emitter: ContextEmitter private context: AutomationContext private job: Job private loopStepOutputs: LoopStep[] @@ -270,20 +289,9 @@ class Orchestrator { appId: this.appId, automationId: this.automation._id, }) - this.context.env = await sdkUtils.getEnvironmentVariables() - this.context.user = this.currentUser - try { - const { config } = await configs.getSettingsConfigDoc() - this.context.settings = { - url: config.platformUrl, - logo: config.logoUrl, - company: config.company, - } - } catch (e) { - // if settings doc doesn't exist, make the settings blank - this.context.settings = {} - } + await enrichBaseContext(this.context) + this.context.user = this.currentUser let metadata diff --git a/packages/server/src/utilities/index.ts b/packages/server/src/utilities/index.ts index db57b4ec12..f1b32c81f3 100644 --- a/packages/server/src/utilities/index.ts +++ b/packages/server/src/utilities/index.ts @@ -58,30 +58,14 @@ export function checkSlashesInUrl(url: string) { export async function updateEntityMetadata( type: string, entityId: string, - updateFn: any + updateFn: (metadata: Document) => Document ) { const db = context.getAppDB() const id = generateMetadataID(type, entityId) - // read it to see if it exists, we'll overwrite it no matter what - let rev, metadata: Document - try { - const oldMetadata = await db.get(id) - rev = oldMetadata._rev - metadata = updateFn(oldMetadata) - } catch (err) { - rev = null - metadata = updateFn({}) - } + const metadata = updateFn((await db.tryGet(id)) || {}) metadata._id = id - if (rev) { - metadata._rev = rev - } const response = await db.put(metadata) - return { - ...metadata, - _id: id, - _rev: response.rev, - } + return { ...metadata, _id: id, _rev: response.rev } } export async function saveEntityMetadata( @@ -89,26 +73,17 @@ export async function saveEntityMetadata( entityId: string, metadata: Document ): Promise { - return updateEntityMetadata(type, entityId, () => { - return metadata - }) + return updateEntityMetadata(type, entityId, () => metadata) } export async function deleteEntityMetadata(type: string, entityId: string) { const db = context.getAppDB() const id = generateMetadataID(type, entityId) - let rev - try { - const metadata = await db.get(id) - if (metadata) { - rev = metadata._rev - } - } catch (err) { - // don't need to error if it doesn't exist - } - if (id && rev) { - await db.remove(id, rev) + const metadata = await db.tryGet(id) + if (!metadata) { + return } + await db.remove(metadata) } export function escapeDangerousCharacters(string: string) { diff --git a/packages/server/src/utilities/redis.ts b/packages/server/src/utilities/redis.ts index a4154b7b95..a3ce655316 100644 --- a/packages/server/src/utilities/redis.ts +++ b/packages/server/src/utilities/redis.ts @@ -89,17 +89,22 @@ export async function setDebounce(id: string, seconds: number) { await debounceClient.store(id, "debouncing", seconds) } -export async function setTestFlag(id: string) { - await flagClient.store(id, { testing: true }, AUTOMATION_TEST_FLAG_SECONDS) -} - export async function checkTestFlag(id: string) { const flag = await flagClient?.get(id) return !!(flag && flag.testing) } -export async function clearTestFlag(id: string) { - await devAppClient.delete(id) +export async function withTestFlag(id: string, fn: () => Promise) { + // TODO(samwho): this has a bit of a problem where if 2 automations are tested + // at the same time, the second one will overwrite the first one's flag. We + // should instead use an atomic counter and only clear the flag when the + // counter reaches 0. + await flagClient.store(id, { testing: true }, AUTOMATION_TEST_FLAG_SECONDS) + try { + return await fn() + } finally { + await devAppClient.delete(id) + } } export function getSocketPubSubClients() { diff --git a/packages/types/src/api/web/app/automation.ts b/packages/types/src/api/web/app/automation.ts index 40f69fc467..572e6499b6 100644 --- a/packages/types/src/api/web/app/automation.ts +++ b/packages/types/src/api/web/app/automation.ts @@ -75,3 +75,10 @@ export interface TestAutomationRequest { row?: Row } export interface TestAutomationResponse {} + +export interface TestAutomationStepRequest { + inputs: Record + context: Record +} + +export type TestAutomationStepResponse = any diff --git a/packages/types/src/documents/app/automation/automation.ts b/packages/types/src/documents/app/automation/automation.ts index d56f0de879..a7556c2ce3 100644 --- a/packages/types/src/documents/app/automation/automation.ts +++ b/packages/types/src/documents/app/automation/automation.ts @@ -1,10 +1,10 @@ import { Document } from "../../document" -import { EventEmitter } from "events" import { User } from "../../global" import { ReadStream } from "fs" import { Row } from "../row" import { Table } from "../table" import { AutomationStep, AutomationTrigger } from "./schema" +import { ContextEmitter } from "../../../sdk" export enum AutomationIOType { OBJECT = "object", @@ -218,7 +218,7 @@ export interface AutomationLogPage { export interface AutomationStepInputBase { context: Record - emitter: EventEmitter + emitter: ContextEmitter appId: string apiKey?: string }