First iteration of single-step automation test endpoint.

This commit is contained in:
Sam Rose 2025-01-20 17:18:29 +00:00
parent b28e4724b1
commit 5bc316916f
No known key found for this signature in database
9 changed files with 172 additions and 76 deletions

View File

@ -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<TestAutomationRequest, TestAutomationResponse>
) {
const db = context.getAppDB()
let automation = await db.get<Automation>(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<Automation>(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<TestAutomationStepRequest, TestAutomationStepResponse>
) {
const { id, stepId } = ctx.params
const db = context.getAppDB()
const automation = await db.tryGet<Automation>(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
}

View File

@ -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

View File

@ -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
}
}

View File

@ -2,5 +2,6 @@ import BudibaseEmitter from "./BudibaseEmitter"
const emitter = new BudibaseEmitter()
export { NoopEmitter } from "./NoopEmitter"
export { init } from "./docUpdates"
export default emitter

View File

@ -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<string, any>) {
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

View File

@ -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<any>(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<Document> {
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<any>(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) {

View File

@ -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<R>(id: string, fn: () => Promise<R>) {
// 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() {

View File

@ -75,3 +75,10 @@ export interface TestAutomationRequest {
row?: Row
}
export interface TestAutomationResponse {}
export interface TestAutomationStepRequest {
inputs: Record<string, any>
context: Record<string, any>
}
export type TestAutomationStepResponse = any

View File

@ -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<string, any>
emitter: EventEmitter
emitter: ContextEmitter
appId: string
apiKey?: string
}