Merge pull request #15402 from Budibase/feature/js-logging

JS console log support
This commit is contained in:
Michael Drury 2025-01-21 14:22:44 +00:00 committed by GitHub
commit 1bed18ad42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 355 additions and 68 deletions

View File

@ -45,6 +45,11 @@
--purple: #806fde; --purple: #806fde;
--purple-dark: #130080; --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-small: 4px;
--rounded-medium: 8px; --rounded-medium: 8px;
--rounded-large: 16px; --rounded-large: 16px;

View File

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

View File

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

View File

@ -4,12 +4,17 @@ import {
JsTimeoutError, JsTimeoutError,
setJSRunner, setJSRunner,
setOnErrorLog, setOnErrorLog,
setTestingBackendJS,
} from "@budibase/string-templates" } from "@budibase/string-templates"
import { context, logging } from "@budibase/backend-core" import { context, logging } from "@budibase/backend-core"
import tracer from "dd-trace" import tracer from "dd-trace"
import { IsolatedVM } from "./vm" import { IsolatedVM } from "./vm"
export function init() { 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>) => { setJSRunner((js: string, ctx: Record<string, any>) => {
return tracer.trace("runJS", {}, () => { return tracer.trace("runJS", {}, () => {
try { try {

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 { LITERAL_MARKER } from "../helpers/constants"
import { getJsHelperList } from "./list" import { getJsHelperList } from "./list"
import { iifeWrapper } from "../iife" import { iifeWrapper } from "../iife"
import { JsTimeoutError, UserScriptError } from "../errors" import { JsTimeoutError, UserScriptError } from "../errors"
import { cloneDeep } from "lodash/fp" 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. // 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). // 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> let clonedContext: Record<string, any>
if (isBackendService()) { 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 // 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. // difference in how JS executes on the frontend vs the backend, e.g.
// consider this snippet: // consider this snippet:
@ -96,10 +103,9 @@ export function processJS(handlebars: string, context: any) {
clonedContext = cloneDeep(context) clonedContext = cloneDeep(context)
} }
const sandboxContext = { const sandboxContext: Record<string, any> = {
$: (path: string) => getContextValue(path, clonedContext), $: (path: string) => getContextValue(path, clonedContext),
helpers: getJsHelperList(), helpers: getJsHelperList(),
// Proxy to evaluate snippets when running in the browser // Proxy to evaluate snippets when running in the browser
snippets: new Proxy( 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 // 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)}}}` return `{{${LITERAL_MARKER} js_result-${JSON.stringify(res)}}}`
} catch (error: any) { } catch (error: any) {
onErrorLog && onErrorLog(error) onErrorLog && onErrorLog(error)

View File

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

View File

@ -1,9 +1,16 @@
import { FIND_HBS_REGEX } from "../utilities" import { FIND_HBS_REGEX } from "../utilities"
import * as preprocessor from "./preprocessor" import * as preprocessor from "./preprocessor"
import type { Preprocessor } from "./preprocessor"
import * as postprocessor from "./postprocessor" 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) { for (let processor of processors) {
// if a literal statement has occurred stop // if a literal statement has occurred stop
if (typeof output !== "string") { if (typeof output !== "string") {
@ -16,10 +23,18 @@ function process(output: string, processors: any[], opts?: ProcessOptions) {
continue continue
} }
for (let match of matches) { 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) { 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) { export function postprocess(string: string) {
return process(string, postprocessor.processors).result
}
export function postprocessWithLogs(string: string) {
return process(string, postprocessor.processors) return process(string, postprocessor.processors)
} }

View File

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

View File

@ -8,3 +8,11 @@ export interface ProcessOptions {
onlyFound?: boolean onlyFound?: boolean
disabledHelpers?: string[] 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 const ALPHA_NUMERIC_REGEX = /^[A-Za-z0-9]+$/g
export const FIND_HBS_REGEX = /{{([^{].*?)}}/g export const FIND_HBS_REGEX = /{{([^{].*?)}}/g
export const FIND_ANY_HBS_REGEX = /{?{{([^{].*?)}}}?/g export const FIND_ANY_HBS_REGEX = /{?{{([^{].*?)}}}?/g
export const FIND_TRIPLE_HBS_REGEX = /{{{([^{].*?)}}}/g export const FIND_TRIPLE_HBS_REGEX = /{{{([^{].*?)}}}/g
const isJest = () => typeof jest !== "undefined"
export const isBackendService = () => { 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 // We consider the tests for string-templates to be frontend, so that they
// test the frontend JS functionality. // test the frontend JS functionality.
if (isJest()) { if (isTest()) {
return false return false
} }
return typeof window === "undefined" return typeof window === "undefined"
@ -86,3 +91,20 @@ export const prefixStrings = (
const regexPattern = new RegExp(`\\b(${escapedStrings.join("|")})\\b`, "g") const regexPattern = new RegExp(`\\b(${escapedStrings.join("|")})\\b`, "g")
return baseString.replace(regexPattern, `${prefix}$1`) 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")
})
})