Merge branch 'master' into type-portal-other-stores

This commit is contained in:
Andrew Kingston 2025-01-23 09:16:20 +00:00 committed by GitHub
commit 887ffe9caf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 649 additions and 227 deletions

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "3.2.46",
"version": "3.3.1",
"npmClient": "yarn",
"concurrency": 20,
"command": {

View File

@ -45,6 +45,11 @@
--purple: #806fde;
--purple-dark: #130080;
--error-bg: rgba(226, 109, 105, 0.3);
--warning-bg: rgba(255, 210, 106, 0.3);
--error-content: rgba(226, 109, 105, 0.6);
--warning-content: rgba(255, 210, 106, 0.6);
--rounded-small: 4px;
--rounded-medium: 8px;
--rounded-large: 16px;

View File

@ -12,7 +12,7 @@
decodeJSBinding,
encodeJSBinding,
processObjectSync,
processStringSync,
processStringWithLogsSync,
} from "@budibase/string-templates"
import { readableToRuntimeBinding } from "@/dataBinding"
import CodeEditor from "../CodeEditor/CodeEditor.svelte"
@ -41,6 +41,7 @@
InsertAtPositionFn,
JSONValue,
} from "@budibase/types"
import type { Log } from "@budibase/string-templates"
import type { CompletionContext } from "@codemirror/autocomplete"
const dispatch = createEventDispatcher()
@ -66,6 +67,7 @@
let insertAtPos: InsertAtPositionFn | undefined
let targetMode: BindingMode | null = null
let expressionResult: string | undefined
let expressionLogs: Log[] | undefined
let expressionError: string | undefined
let evaluating = false
@ -157,7 +159,7 @@
(expression: string | null, context: any, snippets: Snippet[]) => {
try {
expressionError = undefined
expressionResult = processStringSync(
const output = processStringWithLogsSync(
expression || "",
{
...context,
@ -167,6 +169,8 @@
noThrow: false,
}
)
expressionResult = output.result
expressionLogs = output.logs
} catch (err: any) {
expressionResult = undefined
expressionError = err
@ -421,6 +425,7 @@
<EvaluationSidePanel
{expressionResult}
{expressionError}
{expressionLogs}
{evaluating}
expression={editorValue ? editorValue : ""}
/>

View File

@ -4,11 +4,13 @@
import { Helpers } from "@budibase/bbui"
import { fade } from "svelte/transition"
import { UserScriptError } from "@budibase/string-templates"
import type { Log } from "@budibase/string-templates"
import type { JSONValue } from "@budibase/types"
// this can be essentially any primitive response from the JS function
export let expressionResult: JSONValue | undefined = undefined
export let expressionError: string | undefined = undefined
export let expressionLogs: Log[] = []
export let evaluating = false
export let expression: string | null = null
@ -16,6 +18,11 @@
$: empty = expression == null || expression?.trim() === ""
$: success = !error && !empty
$: highlightedResult = highlight(expressionResult)
$: highlightedLogs = expressionLogs.map(l => ({
log: highlight(l.log.join(", ")),
line: l.line,
type: l.type,
}))
const formatError = (err: any) => {
if (err.code === UserScriptError.code) {
@ -25,14 +32,14 @@
}
// json can be any primitive type
const highlight = (json?: any | null) => {
const highlight = (json?: JSONValue | null) => {
if (json == null) {
return ""
}
// Attempt to parse and then stringify, in case this is valid result
try {
json = JSON.stringify(JSON.parse(json), null, 2)
json = JSON.stringify(JSON.parse(json as any), null, 2)
} catch (err) {
// couldn't parse/stringify, just treat it as the raw input
}
@ -61,7 +68,7 @@
<div class="header" class:success class:error>
<div class="header-content">
{#if error}
<Icon name="Alert" color="var(--spectrum-global-color-red-600)" />
<Icon name="Alert" color="var(--error-content)" />
<div>Error</div>
{#if evaluating}
<div transition:fade|local={{ duration: 130 }}>
@ -90,8 +97,36 @@
{:else if error}
{formatError(expressionError)}
{:else}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html highlightedResult}
<div class="output-lines">
{#each highlightedLogs as logLine}
<div
class="line"
class:error-log={logLine.type === "error"}
class:warn-log={logLine.type === "warn"}
>
<div class="icon-log">
{#if logLine.type === "error"}
<Icon
size="XS"
name="CloseCircle"
color="var(--error-content)"
/>
{:else if logLine.type === "warn"}
<Icon size="XS" name="Alert" color="var(--warning-content)" />
{/if}
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
<span>{@html logLine.log}</span>
</div>
{#if logLine.line}
<span style="color: var(--blue)">:{logLine.line}</span>
{/if}
</div>
{/each}
<div class="line">
<!-- eslint-disable-next-line svelte/no-at-html-tags-->
{@html highlightedResult}
</div>
</div>
{/if}
</div>
</div>
@ -130,20 +165,37 @@
height: 100%;
z-index: 1;
position: absolute;
opacity: 10%;
}
.header.error::before {
background: var(--spectrum-global-color-red-400);
background: var(--error-bg);
}
.body {
flex: 1 1 auto;
padding: var(--spacing-m) var(--spacing-l);
font-family: var(--font-mono);
font-size: 12px;
overflow-y: scroll;
overflow-y: auto;
overflow-x: hidden;
white-space: pre-wrap;
white-space: pre-line;
word-wrap: break-word;
height: 0;
}
.output-lines {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.line {
border-bottom: var(--border-light);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: end;
padding: var(--spacing-s);
}
.icon-log {
display: flex;
gap: var(--spacing-s);
align-items: start;
}
</style>

View File

@ -1,4 +1,5 @@
<script>
import { datasources } from "@/stores/builder"
import { Divider, Heading } from "@budibase/bbui"
export let dividerState
@ -6,6 +7,8 @@
export let dataSet
export let value
export let onSelect
$: displayDatasourceName = $datasources.list.length > 1
</script>
{#if dividerState}
@ -21,7 +24,7 @@
{#each dataSet as data}
<li
class="spectrum-Menu-item"
class:is-selected={value?.label === data.label &&
class:is-selected={value?.resourceId === data.resourceId &&
value?.type === data.type}
role="option"
aria-selected="true"
@ -29,7 +32,9 @@
on:click={() => onSelect(data)}
>
<span class="spectrum-Menu-itemLabel">
{data.datasourceName ? `${data.datasourceName} - ` : ""}{data.label}
{data.datasourceName && displayDatasourceName
? `${data.datasourceName} - `
: ""}{data.label}
</span>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"

View File

@ -34,7 +34,7 @@
import ClientBindingPanel from "@/components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "@/components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "@/api"
import { datasourceSelect as format } from "@/helpers/data/format"
import { sortAndFormat } from "@/helpers/data/format"
export let value = {}
export let otherSources
@ -51,25 +51,13 @@
let modal
$: text = value?.label ?? "Choose an option"
$: tables = $tablesStore.list
.map(table => format.table(table, $datasources.list))
.sort((a, b) => {
// sort tables alphabetically, grouped by datasource
const dsA = a.datasourceName ?? ""
const dsB = b.datasourceName ?? ""
const dsComparison = dsA.localeCompare(dsB)
if (dsComparison !== 0) {
return dsComparison
}
return a.label.localeCompare(b.label)
})
$: tables = sortAndFormat.tables($tablesStore.list, $datasources.list)
$: viewsV1 = $viewsStore.list.map(view => ({
...view,
label: view.name,
type: "view",
}))
$: viewsV2 = $viewsV2Store.list.map(format.viewV2)
$: viewsV2 = sortAndFormat.viewsV2($viewsV2Store.list, $datasources.list)
$: views = [...(viewsV1 || []), ...(viewsV2 || [])]
$: queries = $queriesStore.list
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable)

View File

@ -1,22 +1,32 @@
<script>
import { Select } from "@budibase/bbui"
import { Popover, Select } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte"
import { tables as tablesStore, viewsV2 } from "@/stores/builder"
import { tableSelect as format } from "@/helpers/data/format"
import {
tables as tableStore,
datasources as datasourceStore,
viewsV2 as viewsV2Store,
} from "@/stores/builder"
import DataSourceCategory from "./DataSourceSelect/DataSourceCategory.svelte"
import { sortAndFormat } from "@/helpers/data/format"
export let value
let anchorRight, dropdownRight
const dispatch = createEventDispatcher()
$: tables = $tablesStore.list.map(format.table)
$: views = $viewsV2.list.map(format.viewV2)
$: tables = sortAndFormat.tables($tableStore.list, $datasourceStore.list)
$: views = sortAndFormat.viewsV2($viewsV2Store.list, $datasourceStore.list)
$: options = [...(tables || []), ...(views || [])]
$: text = value?.label ?? "Choose an option"
const onChange = e => {
dispatch(
"change",
options.find(x => x.resourceId === e.detail)
options.find(x => x.resourceId === e.resourceId)
)
dropdownRight.hide()
}
onMount(() => {
@ -29,10 +39,47 @@
})
</script>
<Select
on:change={onChange}
value={value?.resourceId}
{options}
getOptionValue={x => x.resourceId}
getOptionLabel={x => x.label}
/>
<div class="container" bind:this={anchorRight}>
<Select
readonly
value={text}
options={[text]}
on:click={dropdownRight.show}
/>
</div>
<Popover bind:this={dropdownRight} anchor={anchorRight}>
<div class="dropdown">
<DataSourceCategory
heading="Tables"
dataSet={tables}
{value}
onSelect={onChange}
/>
{#if views?.length}
<DataSourceCategory
dividerState={true}
heading="Views"
dataSet={views}
{value}
onSelect={onChange}
/>
{/if}
</div>
</Popover>
<style>
.container {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
.container :global(:first-child) {
flex: 1 1 auto;
}
.dropdown {
padding: var(--spacing-m) 0;
z-index: 99999999;
}
</style>

View File

@ -9,11 +9,18 @@ export const datasourceSelect = {
datasourceName: datasource?.name,
}
},
viewV2: view => ({
...view,
label: view.name,
type: "viewV2",
}),
viewV2: (view, datasources) => {
const datasource = datasources
?.filter(f => f.entities)
.flatMap(d => d.entities)
.find(ds => ds._id === view.tableId)
return {
...view,
label: view.name,
type: "viewV2",
datasourceName: datasource?.name,
}
},
}
export const tableSelect = {
@ -31,3 +38,36 @@ export const tableSelect = {
resourceId: view.id,
}),
}
export const sortAndFormat = {
tables: (tables, datasources) => {
return tables
.map(table => {
const formatted = datasourceSelect.table(table, datasources)
return {
...formatted,
resourceId: table._id,
}
})
.sort((a, b) => {
// sort tables alphabetically, grouped by datasource
const dsA = a.datasourceName ?? ""
const dsB = b.datasourceName ?? ""
const dsComparison = dsA.localeCompare(dsB)
if (dsComparison !== 0) {
return dsComparison
}
return a.label.localeCompare(b.label)
})
},
viewsV2: (views, datasources) => {
return views.map(view => {
const formatted = datasourceSelect.viewV2(view, datasources)
return {
...formatted,
resourceId: view.id,
}
})
},
}

View File

@ -15,6 +15,7 @@
import {
appsStore,
organisation,
admin,
auth,
groups,
licensing,
@ -42,6 +43,7 @@
app => app.status === AppStatus.DEPLOYED
)
$: userApps = getUserApps(publishedApps, userGroups, $auth.user)
$: isOwner = $auth.accountPortalAccess && $admin.cloud
function getUserApps(publishedApps, userGroups, user) {
if (sdk.users.isAdmin(user)) {
@ -111,7 +113,13 @@
</MenuItem>
<MenuItem
icon="LockClosed"
on:click={() => changePasswordModal.show()}
on:click={() => {
if (isOwner) {
window.location.href = `${$admin.accountPortalUrl}/portal/account`
} else {
changePasswordModal.show()
}
}}
>
Update password
</MenuItem>

View File

@ -1,5 +1,5 @@
<script>
import { auth } from "@/stores/portal"
import { admin, auth } from "@/stores/portal"
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import ProfileModal from "@/components/settings/ProfileModal.svelte"
@ -13,6 +13,8 @@
let updatePasswordModal
let apiKeyModal
$: isOwner = $auth.accountPortalAccess && $admin.cloud
const logout = async () => {
try {
await auth.logout()
@ -32,7 +34,16 @@
</MenuItem>
<MenuItem icon="Moon" on:click={() => themeModal.show()}>Theme</MenuItem>
{#if !$auth.isSSO}
<MenuItem icon="LockClosed" on:click={() => updatePasswordModal.show()}>
<MenuItem
icon="LockClosed"
on:click={() => {
if (isOwner) {
window.location.href = `${$admin.accountPortalUrl}/portal/account`
} else {
updatePasswordModal.show()
}
}}
>
Update password
</MenuItem>
{/if}

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 {
@ -231,24 +231,25 @@ 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)
}

View File

@ -5,8 +5,11 @@ import {
sendAutomationAttachmentsToStorage,
} from "../automationUtils"
import { buildCtx } from "./utils"
import { CreateRowStepInputs, CreateRowStepOutputs } from "@budibase/types"
import { EventEmitter } from "events"
import {
ContextEmitter,
CreateRowStepInputs,
CreateRowStepOutputs,
} from "@budibase/types"
export async function run({
inputs,
@ -15,7 +18,7 @@ export async function run({
}: {
inputs: CreateRowStepInputs
appId: string
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<CreateRowStepOutputs> {
if (inputs.row == null || inputs.row.tableId == null) {
return {

View File

@ -1,8 +1,11 @@
import { EventEmitter } from "events"
import { destroy } from "../../api/controllers/row"
import { buildCtx } from "./utils"
import { getError } from "../automationUtils"
import { DeleteRowStepInputs, DeleteRowStepOutputs } from "@budibase/types"
import {
ContextEmitter,
DeleteRowStepInputs,
DeleteRowStepOutputs,
} from "@budibase/types"
export async function run({
inputs,
@ -11,7 +14,7 @@ export async function run({
}: {
inputs: DeleteRowStepInputs
appId: string
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<DeleteRowStepOutputs> {
if (inputs.id == null) {
return {

View File

@ -1,8 +1,8 @@
import { EventEmitter } from "events"
import * as queryController from "../../api/controllers/query"
import { buildCtx } from "./utils"
import * as automationUtils from "../automationUtils"
import {
ContextEmitter,
ExecuteQueryStepInputs,
ExecuteQueryStepOutputs,
} from "@budibase/types"
@ -14,7 +14,7 @@ export async function run({
}: {
inputs: ExecuteQueryStepInputs
appId: string
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<ExecuteQueryStepOutputs> {
if (inputs.query == null) {
return {

View File

@ -2,10 +2,10 @@ import * as scriptController from "../../api/controllers/script"
import { buildCtx } from "./utils"
import * as automationUtils from "../automationUtils"
import {
ContextEmitter,
ExecuteScriptStepInputs,
ExecuteScriptStepOutputs,
} from "@budibase/types"
import { EventEmitter } from "events"
export async function run({
inputs,
@ -16,7 +16,7 @@ export async function run({
inputs: ExecuteScriptStepInputs
appId: string
context: object
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<ExecuteScriptStepOutputs> {
if (inputs.code == null) {
return {

View File

@ -1,8 +1,11 @@
import { EventEmitter } from "events"
import * as rowController from "../../api/controllers/row"
import * as automationUtils from "../automationUtils"
import { buildCtx } from "./utils"
import { UpdateRowStepInputs, UpdateRowStepOutputs } from "@budibase/types"
import {
ContextEmitter,
UpdateRowStepInputs,
UpdateRowStepOutputs,
} from "@budibase/types"
export async function run({
inputs,
@ -11,7 +14,7 @@ export async function run({
}: {
inputs: UpdateRowStepInputs
appId: string
emitter: EventEmitter
emitter: ContextEmitter
}): Promise<UpdateRowStepOutputs> {
if (inputs.rowId == null || inputs.row == null) {
return {

View File

@ -1,4 +1,4 @@
import { EventEmitter } from "events"
import { ContextEmitter } from "@budibase/types"
export async function getFetchResponse(fetched: any) {
let status = fetched.status,
@ -22,7 +22,7 @@ export async function getFetchResponse(fetched: any) {
// opts can contain, body, params and version
export function buildCtx(
appId: string,
emitter?: EventEmitter | null,
emitter?: ContextEmitter | null,
opts: any = {}
) {
const ctx: any = {

View File

@ -1,5 +1,4 @@
import { v4 as uuidv4 } from "uuid"
import { testAutomation } from "../../../api/routes/tests/utilities/TestFunctions"
import { BUILTIN_ACTION_DEFINITIONS } from "../../actions"
import { TRIGGER_DEFINITIONS } from "../../triggers"
import {
@ -7,7 +6,6 @@ import {
AppActionTriggerOutputs,
Automation,
AutomationActionStepId,
AutomationResults,
AutomationStep,
AutomationStepInputs,
AutomationTrigger,
@ -24,6 +22,7 @@ import {
ExecuteQueryStepInputs,
ExecuteScriptStepInputs,
FilterStepInputs,
isDidNotTriggerResponse,
LoopStepInputs,
OpenAIStepInputs,
QueryRowsStepInputs,
@ -36,6 +35,7 @@ import {
SearchFilters,
ServerLogStepInputs,
SmtpEmailStepInputs,
TestAutomationRequest,
UpdateRowStepInputs,
WebhookTriggerInputs,
WebhookTriggerOutputs,
@ -279,7 +279,7 @@ class StepBuilder extends BaseStepBuilder {
class AutomationBuilder extends BaseStepBuilder {
private automationConfig: Automation
private config: TestConfiguration
private triggerOutputs: any
private triggerOutputs: TriggerOutputs
private triggerSet = false
constructor(
@ -398,21 +398,19 @@ class AutomationBuilder extends BaseStepBuilder {
async run() {
const automation = await this.save()
const results = await testAutomation(
this.config,
automation,
this.triggerOutputs
const response = await this.config.api.automation.test(
automation._id!,
this.triggerOutputs as TestAutomationRequest
)
return this.processResults(results)
}
private processResults(results: {
body: AutomationResults
}): AutomationResults {
results.body.steps.shift()
if (isDidNotTriggerResponse(response)) {
throw new Error(response.message)
}
response.steps.shift()
return {
trigger: results.body.trigger,
steps: results.body.steps,
trigger: response.trigger,
steps: response.steps,
}
}
}

View File

@ -21,6 +21,7 @@ import {
AutomationRowEvent,
UserBindings,
AutomationResults,
DidNotTriggerResponse,
} from "@budibase/types"
import { executeInThread } from "../threads/automation"
import { dataFilters, sdk } from "@budibase/shared-core"
@ -33,14 +34,6 @@ const JOB_OPTS = {
import * as automationUtils from "../automations/automationUtils"
import { doesTableExist } from "../sdk/app/tables/getters"
type DidNotTriggerResponse = {
outputs: {
success: false
status: AutomationStatus.STOPPED
}
message: AutomationStoppedReason.TRIGGER_FILTER_NOT_MET
}
async function getAllAutomations() {
const db = context.getAppDB()
let automations = await db.allDocs<Automation>(
@ -156,14 +149,26 @@ export function isAutomationResults(
)
}
interface AutomationTriggerParams {
fields: Record<string, any>
timeout?: number
appId?: string
user?: UserBindings
}
export async function externalTrigger(
automation: Automation,
params: {
fields: Record<string, any>
timeout?: number
appId?: string
user?: UserBindings
},
params: AutomationTriggerParams,
options: { getResponses: true }
): Promise<AutomationResults | DidNotTriggerResponse>
export async function externalTrigger(
automation: Automation,
params: AutomationTriggerParams,
options?: { getResponses: false }
): Promise<AutomationJob | DidNotTriggerResponse>
export async function externalTrigger(
automation: Automation,
params: AutomationTriggerParams,
{ getResponses }: { getResponses?: boolean } = {}
): Promise<AutomationResults | DidNotTriggerResponse | AutomationJob> {
if (automation.disabled) {

View File

@ -4,12 +4,17 @@ import {
JsTimeoutError,
setJSRunner,
setOnErrorLog,
setTestingBackendJS,
} from "@budibase/string-templates"
import { context, logging } from "@budibase/backend-core"
import tracer from "dd-trace"
import { IsolatedVM } from "./vm"
export function init() {
// enforce that if we're using isolated-VM runner then we are running backend JS
if (env.isTest()) {
setTestingBackendJS()
}
setJSRunner((js: string, ctx: Record<string, any>) => {
return tracer.trace("runJS", {}, () => {
try {

View File

@ -1,4 +1,9 @@
import { Automation, FetchAutomationResponse } from "@budibase/types"
import {
Automation,
FetchAutomationResponse,
TestAutomationRequest,
TestAutomationResponse,
} from "@budibase/types"
import { Expectations, TestAPI } from "./base"
export class AutomationAPI extends TestAPI {
@ -33,4 +38,18 @@ export class AutomationAPI extends TestAPI {
})
return result
}
test = async (
id: string,
body: TestAutomationRequest,
expectations?: Expectations
): Promise<TestAutomationResponse> => {
return await this._post<TestAutomationResponse>(
`/api/automations/${id}/test`,
{
body,
expectations,
}
)
}
}

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

@ -0,0 +1,23 @@
function isJest() {
return (
process.env.NODE_ENV === "jest" ||
(process.env.JEST_WORKER_ID != null &&
process.env.JEST_WORKER_ID !== "null")
)
}
export function isTest() {
return isJest()
}
export const isJSAllowed = () => {
return process && !process.env.NO_JS
}
export const isTestingBackendJS = () => {
return process && process.env.BACKEND_JS
}
export const setTestingBackendJS = () => {
process.env.BACKEND_JS = "1"
}

View File

@ -1,9 +1,16 @@
import { atob, isBackendService, isJSAllowed } from "../utilities"
import {
atob,
frontendWrapJS,
isBackendService,
isJSAllowed,
} from "../utilities"
import { LITERAL_MARKER } from "../helpers/constants"
import { getJsHelperList } from "./list"
import { iifeWrapper } from "../iife"
import { JsTimeoutError, UserScriptError } from "../errors"
import { cloneDeep } from "lodash/fp"
import { Log, LogType } from "../types"
import { isTest } from "../environment"
// The method of executing JS scripts depends on the bundle being built.
// This setter is used in the entrypoint (either index.js or index.mjs).
@ -81,7 +88,7 @@ export function processJS(handlebars: string, context: any) {
let clonedContext: Record<string, any>
if (isBackendService()) {
// On the backned, values are copied across the isolated-vm boundary and
// On the backend, values are copied across the isolated-vm boundary and
// so we don't need to do any cloning here. This does create a fundamental
// difference in how JS executes on the frontend vs the backend, e.g.
// consider this snippet:
@ -96,10 +103,9 @@ export function processJS(handlebars: string, context: any) {
clonedContext = cloneDeep(context)
}
const sandboxContext = {
const sandboxContext: Record<string, any> = {
$: (path: string) => getContextValue(path, clonedContext),
helpers: getJsHelperList(),
// Proxy to evaluate snippets when running in the browser
snippets: new Proxy(
{},
@ -114,8 +120,49 @@ export function processJS(handlebars: string, context: any) {
),
}
const logs: Log[] = []
// logging only supported on frontend
if (!isBackendService()) {
// this counts the lines in the wrapped JS *before* the user's code, so that we can minus it
const jsLineCount = frontendWrapJS(js).split(js)[0].split("\n").length
const buildLogResponse = (type: LogType) => {
return (...props: any[]) => {
if (!isTest()) {
console[type](...props)
}
props.forEach((prop, index) => {
if (typeof prop === "object") {
props[index] = JSON.stringify(prop)
}
})
// quick way to find out what line this is being called from
// its an anonymous function and we look for the overall length to find the
// line number we care about (from the users function)
// JS stack traces are in the format function:line:column
const lineNumber = new Error().stack?.match(
/<anonymous>:(\d+):\d+/
)?.[1]
logs.push({
log: props,
line: lineNumber ? parseInt(lineNumber) - jsLineCount : undefined,
type,
})
}
}
sandboxContext.console = {
log: buildLogResponse("log"),
info: buildLogResponse("info"),
debug: buildLogResponse("debug"),
warn: buildLogResponse("warn"),
error: buildLogResponse("error"),
// table should be treated differently, but works the same
// as the rest of the logs for now
table: buildLogResponse("table"),
}
}
// Create a sandbox with our context and run the JS
const res = { data: runJS(js, sandboxContext) }
const res = { data: runJS(js, sandboxContext), logs }
return `{{${LITERAL_MARKER} js_result-${JSON.stringify(res)}}}`
} catch (error: any) {
onErrorLog && onErrorLog(error)

View File

@ -1,13 +1,14 @@
import { createContext, runInNewContext } from "vm"
import { create, TemplateDelegate } from "handlebars"
import { registerAll, registerMinimum } from "./helpers/index"
import { postprocess, preprocess } from "./processors"
import { postprocess, postprocessWithLogs, preprocess } from "./processors"
import {
atob,
btoa,
FIND_ANY_HBS_REGEX,
FIND_HBS_REGEX,
findDoubleHbsInstances,
frontendWrapJS,
isBackendService,
prefixStrings,
} from "./utilities"
@ -15,9 +16,11 @@ import { convertHBSBlock } from "./conversion"
import { removeJSRunner, setJSRunner } from "./helpers/javascript"
import manifest from "./manifest.json"
import { ProcessOptions } from "./types"
import { Log, ProcessOptions } from "./types"
import { UserScriptError } from "./errors"
export type { Log, LogType } from "./types"
export { setTestingBackendJS } from "./environment"
export { helpersToRemoveForJs, getJsHelperList } from "./helpers/list"
export { FIND_ANY_HBS_REGEX } from "./utilities"
export { setJSRunner, setOnErrorLog } from "./helpers/javascript"
@ -187,23 +190,27 @@ export function processObjectSync(
return object
}
/**
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call.
* @param {string} string The template string which is the filled from the context object.
* @param {object} context An object of information which will be used to enrich the string.
* @param {object|undefined} [opts] optional - specify some options for processing.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
export function processStringSync(
// keep the logging function internal, don't want to add this to the process options directly
// as it can't be used for object processing etc.
function processStringSyncInternal(
str: string,
context?: object,
opts?: ProcessOptions & { logging: false }
): string
function processStringSyncInternal(
str: string,
context?: object,
opts?: ProcessOptions & { logging: true }
): { result: string; logs: Log[] }
function processStringSyncInternal(
string: string,
context?: object,
opts?: ProcessOptions
): string {
opts?: ProcessOptions & { logging: boolean }
): string | { result: string; logs: Log[] } {
// Take a copy of input in case of error
const input = string
if (typeof string !== "string") {
throw "Cannot process non-string types."
throw new Error("Cannot process non-string types.")
}
function process(stringPart: string) {
// context is needed to check for overlap between helpers and context
@ -217,16 +224,24 @@ export function processStringSync(
},
...context,
})
return postprocess(processedString)
return opts?.logging
? postprocessWithLogs(processedString)
: postprocess(processedString)
}
try {
if (opts && opts.onlyFound) {
let logs: Log[] = []
const blocks = findHBSBlocks(string)
for (let block of blocks) {
const outcome = process(block)
string = string.replace(block, outcome)
if (typeof outcome === "object" && "result" in outcome) {
logs = logs.concat(outcome.logs || [])
string = string.replace(block, outcome.result)
} else {
string = string.replace(block, outcome)
}
}
return string
return !opts?.logging ? string : { result: string, logs }
} else {
return process(string)
}
@ -239,6 +254,42 @@ export function processStringSync(
}
}
/**
* This will process a single handlebars containing string. If the string passed in has no valid handlebars statements
* then nothing will occur. This is a pure sync call and therefore does not have the full functionality of the async call.
* @param {string} string The template string which is the filled from the context object.
* @param {object} context An object of information which will be used to enrich the string.
* @param {object|undefined} [opts] optional - specify some options for processing.
* @returns {string} The enriched string, all templates should have been replaced if they can be.
*/
export function processStringSync(
string: string,
context?: object,
opts?: ProcessOptions
): string {
return processStringSyncInternal(string, context, {
...opts,
logging: false,
})
}
/**
* Same as function above, but allows logging to be returned - this is only for JS bindings.
*/
export function processStringWithLogsSync(
string: string,
context?: object,
opts?: ProcessOptions
): { result: string; logs: Log[] } {
if (isBackendService()) {
throw new Error("Logging disabled for backend bindings")
}
return processStringSyncInternal(string, context, {
...opts,
logging: true,
})
}
/**
* By default with expressions like {{ name }} handlebars will escape various
* characters, which can be problematic. To fix this we use the syntax {{{ name }}},
@ -462,20 +513,7 @@ export function browserJSSetup() {
setJSRunner((js: string, context: Record<string, any>) => {
createContext(context)
const wrappedJs = `
result = {
result: null,
error: null,
};
try {
result.result = ${js};
} catch (e) {
result.error = e;
}
result;
`
const wrappedJs = frontendWrapJS(js)
const result = runInNewContext(wrappedJs, context, { timeout: 1000 })
if (result.error) {

View File

@ -1,9 +1,16 @@
import { FIND_HBS_REGEX } from "../utilities"
import * as preprocessor from "./preprocessor"
import type { Preprocessor } from "./preprocessor"
import * as postprocessor from "./postprocessor"
import { ProcessOptions } from "../types"
import type { Postprocessor } from "./postprocessor"
import { Log, ProcessOptions } from "../types"
function process(output: string, processors: any[], opts?: ProcessOptions) {
function process(
output: string,
processors: (Preprocessor | Postprocessor)[],
opts?: ProcessOptions
) {
let logs: Log[] = []
for (let processor of processors) {
// if a literal statement has occurred stop
if (typeof output !== "string") {
@ -16,10 +23,18 @@ function process(output: string, processors: any[], opts?: ProcessOptions) {
continue
}
for (let match of matches) {
output = processor.process(output, match, opts)
const res = processor.process(output, match, opts || {})
if (typeof res === "object") {
if ("logs" in res && res.logs) {
logs = logs.concat(res.logs)
}
output = res.result
} else {
output = res as string
}
}
}
return output
return { result: output, logs }
}
export function preprocess(string: string, opts: ProcessOptions) {
@ -30,8 +45,13 @@ export function preprocess(string: string, opts: ProcessOptions) {
)
}
return process(string, processors, opts)
return process(string, processors, opts).result
}
export function postprocess(string: string) {
return process(string, postprocessor.processors).result
}
export function postprocessWithLogs(string: string) {
return process(string, postprocessor.processors)
}

View File

@ -1,12 +1,16 @@
import { LITERAL_MARKER } from "../helpers/constants"
import { Log } from "../types"
export enum PostProcessorNames {
CONVERT_LITERALS = "convert-literals",
}
type PostprocessorFn = (statement: string) => string
export type PostprocessorFn = (statement: string) => {
result: any
logs?: Log[]
}
class Postprocessor {
export class Postprocessor {
name: PostProcessorNames
private readonly fn: PostprocessorFn
@ -23,12 +27,12 @@ class Postprocessor {
export const processors = [
new Postprocessor(
PostProcessorNames.CONVERT_LITERALS,
(statement: string) => {
(statement: string): { result: any; logs?: Log[] } => {
if (
typeof statement !== "string" ||
!statement.includes(LITERAL_MARKER)
) {
return statement
return { result: statement }
}
const splitMarkerIndex = statement.indexOf("-")
const type = statement.substring(12, splitMarkerIndex)
@ -38,20 +42,22 @@ export const processors = [
)
switch (type) {
case "string":
return value
return { result: value }
case "number":
return parseFloat(value)
return { result: parseFloat(value) }
case "boolean":
return value === "true"
return { result: value === "true" }
case "object":
return JSON.parse(value)
case "js_result":
return { result: JSON.parse(value) }
case "js_result": {
// We use the literal helper to process the result of JS expressions
// as we want to be able to return any types.
// We wrap the value in an abject to be able to use undefined properly.
return JSON.parse(value).data
const parsed = JSON.parse(value)
return { result: parsed.data, logs: parsed.logs }
}
}
return value
return { result: value }
}
),
]

View File

@ -11,9 +11,12 @@ export enum PreprocessorNames {
NORMALIZE_SPACES = "normalize-spaces",
}
type PreprocessorFn = (statement: string, opts?: ProcessOptions) => string
export type PreprocessorFn = (
statement: string,
opts?: ProcessOptions
) => string
class Preprocessor {
export class Preprocessor {
name: string
private readonly fn: PreprocessorFn

View File

@ -8,3 +8,11 @@ export interface ProcessOptions {
onlyFound?: boolean
disabledHelpers?: string[]
}
export type LogType = "log" | "info" | "debug" | "warn" | "error" | "table"
export interface Log {
log: any[]
line?: number
type?: LogType
}

View File

@ -1,15 +1,20 @@
import { isTest, isTestingBackendJS } from "./environment"
const ALPHA_NUMERIC_REGEX = /^[A-Za-z0-9]+$/g
export const FIND_HBS_REGEX = /{{([^{].*?)}}/g
export const FIND_ANY_HBS_REGEX = /{?{{([^{].*?)}}}?/g
export const FIND_TRIPLE_HBS_REGEX = /{{{([^{].*?)}}}/g
const isJest = () => typeof jest !== "undefined"
export const isBackendService = () => {
// allow configuring backend JS mode when testing - we default to assuming
// frontend, but need a method to control this
if (isTest() && isTestingBackendJS()) {
return true
}
// We consider the tests for string-templates to be frontend, so that they
// test the frontend JS functionality.
if (isJest()) {
if (isTest()) {
return false
}
return typeof window === "undefined"
@ -86,3 +91,20 @@ export const prefixStrings = (
const regexPattern = new RegExp(`\\b(${escapedStrings.join("|")})\\b`, "g")
return baseString.replace(regexPattern, `${prefix}$1`)
}
export function frontendWrapJS(js: string) {
return `
result = {
result: null,
error: null,
};
try {
result.result = ${js};
} catch (e) {
result.error = e;
}
result;
`
}

View File

@ -0,0 +1,53 @@
import {
processStringWithLogsSync,
encodeJSBinding,
defaultJSSetup,
} from "../src/index"
const processJS = (js: string, context?: object) => {
return processStringWithLogsSync(encodeJSBinding(js), context)
}
describe("Javascript", () => {
beforeAll(() => {
defaultJSSetup()
})
describe("Test logging in JS bindings", () => {
it("should execute a simple expression", () => {
const output = processJS(
`console.log("hello");
console.log("world");
console.log("foo");
return "hello"`
)
expect(output.result).toEqual("hello")
expect(output.logs[0].log).toEqual(["hello"])
expect(output.logs[0].line).toEqual(1)
expect(output.logs[1].log).toEqual(["world"])
expect(output.logs[1].line).toEqual(2)
expect(output.logs[2].log).toEqual(["foo"])
expect(output.logs[2].line).toEqual(3)
})
})
it("should log comma separated values", () => {
const output = processJS(`console.log(1, { a: 1 }); return 1`)
expect(output.logs[0].log).toEqual([1, JSON.stringify({ a: 1 })])
expect(output.logs[0].line).toEqual(1)
})
it("should return the type working with warn", () => {
const output = processJS(`console.warn("warning"); return 1`)
expect(output.logs[0].log).toEqual(["warning"])
expect(output.logs[0].line).toEqual(1)
expect(output.logs[0].type).toEqual("warn")
})
it("should return the type working with error", () => {
const output = processJS(`console.error("error"); return 1`)
expect(output.logs[0].log).toEqual(["error"])
expect(output.logs[0].line).toEqual(1)
expect(output.logs[0].type).toEqual("error")
})
})

View File

@ -2,10 +2,12 @@ import {
Automation,
AutomationActionStepId,
AutomationLogPage,
AutomationResults,
AutomationStatus,
AutomationStepDefinition,
AutomationTriggerDefinition,
AutomationTriggerStepId,
DidNotTriggerResponse,
Row,
} from "../../../documents"
import { DocumentDestroyResponse } from "@budibase/nano"
@ -74,4 +76,10 @@ export interface TestAutomationRequest {
fields: Record<string, any>
row?: Row
}
export interface TestAutomationResponse {}
export type TestAutomationResponse = AutomationResults | DidNotTriggerResponse
export function isDidNotTriggerResponse(
response: TestAutomationResponse
): response is DidNotTriggerResponse {
return !!("message" in response && response.message)
}

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",
@ -205,6 +205,14 @@ export interface AutomationResults {
}[]
}
export interface DidNotTriggerResponse {
outputs: {
success: false
status: AutomationStatus.STOPPED
}
message: AutomationStoppedReason.TRIGGER_FILTER_NOT_MET
}
export interface AutomationLog extends AutomationResults, Document {
automationName: string
_rev?: string
@ -218,7 +226,7 @@ export interface AutomationLogPage {
export interface AutomationStepInputBase {
context: Record<string, any>
emitter: EventEmitter
emitter: ContextEmitter
appId: string
apiKey?: string
}