Files
windmill/backend/windmill-jseval/src/lib.rs
T
Ruben FiszelandClaude Opus 4.8 95d9ff02ee fix(jseval): raise QuickJS eval memory cap to 128MB with clear OOM error (#10116)
* fix(jseval): raise QuickJS eval memory cap to 128MB with clear OOM error

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(jseval): note bare null/undefined throws are absorbed into OOM bucket

Addresses CI review P2 nit on map_quickjs_error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(jseval): keep batch-rerun eval on a conservative 32MB cap; tighten OOM match

Addresses CI review: eval_simple_js runs in the API process with unbounded request concurrency, so it must not inherit the raised flow-transform cap. Tighten the Exception OOM match to exact string. Reword drafting-history comments per AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(jseval): gate OOM on InternalError kind; path-specific remediation hint

Require the OOM InternalError name (not just the message) so a user throw new Error('out of memory') is not misclassified, and only suggest QUICKJS_MEMORY_LIMIT_MB on the env-tunable flow path (not the fixed-cap eval_simple_js path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:35:55 +02:00

2985 lines
104 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! QuickJS-based JavaScript expression evaluation for flow transformations.
//!
//! This crate provides fast, lightweight JS expression evaluation using QuickJS (rquickjs).
//! It is used by both windmill-worker (for flow expression eval) and windmill-api (for batch rerun).
//!
//! ## Performance Characteristics (release mode benchmarks)
//! - **Simple expressions**: ~238μs (QuickJS) vs ~3.05ms (deno_core) = **~13x faster**
//! - **Complex expressions**: ~192μs (QuickJS) vs ~3.09ms (deno_core) = **~16x faster**
//! - **Memory**: ~2.5% of V8's footprint
use std::collections::HashMap;
use std::sync::Arc;
pub const EVAL_TIMEOUT_MS: u64 = 20000;
use lazy_static::lazy_static;
use regex::Regex;
#[cfg(feature = "quickjs")]
use rquickjs::{
async_with,
prelude::{Async, Func, MutFn},
AsyncContext, AsyncRuntime, CatchResultExt, FromJs, IntoJs, Object, Value,
};
use serde_json::value::RawValue;
use uuid::Uuid;
use windmill_common::client::AuthedClient;
use windmill_common::flow_status::JobResult;
// ── Public types ──────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct IdContext {
pub flow_job: Uuid,
pub root_flow_job: Uuid,
#[allow(dead_code)]
pub steps_results: HashMap<String, JobResult>,
pub previous_id: String,
}
// ── Constants ─────────────────────────────────────────────────────────
const FLOW_INPUT_PREFIX: &str = "flow_input";
const ENV_KEY_PREFIX: &str = "flow_env";
const DOT_PATTERN: &str = ".";
const START_BRACKET_PATTERN: &str = "[\"";
const END_BRACKET_PATTERN: &str = "\"]";
// ── Regex statics ─────────────────────────────────────────────────────
lazy_static! {
// `results` is fetched lazily via the `__getResult` async proxy, so we
// wrap each `results.X` access with `(await ...)` to drive the proxy.
// `flow_env` used to be wrapped here too (it was an async Deno op-backed
// proxy in the deno_core era); QuickJS now exposes flow_env as a plain
// in-memory object, so no await is needed.
static ref RE: Regex = Regex::new(
r#"(?m)(?P<r>results(?:\?)?(?:(?:\.[a-zA-Z_0-9]+)|(?:\[\".*?\"\])))"#
)
.unwrap();
// SQL fast-path: simple `results.X.Y[i]...` accesses are dispatched to
// the API endpoint to fetch a specific result without spinning the eval
// engine. flow_env is no longer dispatched here because QuickJS reads
// it directly from the in-process global set up by `eval_quickjs_inner`.
static ref RE_FULL: Regex = Regex::new(
r"(?m)^results(?:\?)?\.([a-zA-Z_0-9]+)(?:\[(\d+)\])?((?:\.[a-zA-Z_0-9]+)+)?$"
)
.unwrap();
}
// ── Shared helper functions (quickjs-only) ──────────────────────────
#[cfg(feature = "quickjs")]
pub fn replace_with_await(expr: String, fn_name: &str) -> String {
let sep = format!("{}(", fn_name);
let mut split = expr.split(&sep);
let mut s = split.next().unwrap_or("").to_string();
for x in split {
s.push_str(&format!("(await {}({}", fn_name, add_closing_bracket(x)))
}
s
}
#[cfg(feature = "quickjs")]
pub fn replace_with_await_result(expr: String) -> String {
RE.replace_all(&expr, "(await $r)").to_string()
}
#[cfg(feature = "quickjs")]
fn add_closing_bracket(s: &str) -> String {
let mut s = s.to_string();
let mut level = 1;
let mut idx = 0;
for c in s.chars() {
match c {
'(' => level += 1,
')' => level -= 1,
_ => (),
};
if level == 0 {
break;
}
idx += 1;
}
s.insert_str(idx, ")");
s
}
pub fn try_exact_property_access(
expr: &str,
flow_input: Option<&mappable_rc::Marc<HashMap<String, Box<RawValue>>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
) -> Option<Box<RawValue>> {
let obj = if expr.starts_with(FLOW_INPUT_PREFIX) {
Some((
FLOW_INPUT_PREFIX,
flow_input.as_ref().map(|obj| obj.as_ref()),
))
} else if expr.starts_with(ENV_KEY_PREFIX) {
Some((ENV_KEY_PREFIX, flow_env))
} else {
None
};
if let Some((prefix, obj)) = obj {
let access_pattern_pos = prefix.len();
let suffix = &expr[access_pattern_pos..];
let maybe_key_name = if suffix.starts_with(DOT_PATTERN) {
let key_name_pos = DOT_PATTERN.len();
Some(&suffix[key_name_pos..])
} else if suffix.starts_with(START_BRACKET_PATTERN) {
let key_name_pos = START_BRACKET_PATTERN.len();
let suffix = &suffix[key_name_pos..];
let flow_arg_name = suffix
.ends_with(END_BRACKET_PATTERN)
.then(|| {
let start_key_name_pos = access_pattern_pos + key_name_pos;
let end_key_name_pos = expr.len() - END_BRACKET_PATTERN.len();
&expr[start_key_name_pos..end_key_name_pos]
})
.filter(|s| s.len() > 0);
flow_arg_name
} else {
None
};
if let Some(key_name) = maybe_key_name {
if let Some(key_value) = obj.and_then(|obj| obj.get(key_name)) {
return Some(key_value.clone());
}
}
}
None
}
/// JS runtime properties (not methods) that cannot be resolved by PostgreSQL's
/// #> JSON path operator. Function calls like .map(...) already don't match the
/// RE_FULL regex due to parentheses, so only property accesses need listing here.
const JS_ONLY_PROPERTIES: &[&str] = &["length"];
fn ends_with_js_only_property(rest: Option<&str>) -> bool {
match rest {
None => false,
Some(rest) => {
let last_segment = rest.rsplit('.').next().unwrap_or("");
JS_ONLY_PROPERTIES.contains(&last_segment)
}
}
}
pub async fn handle_full_regex(
expr: &str,
authed_client: &AuthedClient,
by_id: &IdContext,
) -> Option<anyhow::Result<Box<RawValue>>> {
if let Some(captures) = RE_FULL.captures(&expr) {
let obj_key = captures.get(1).unwrap().as_str();
let idx_o = captures.get(2).map(|y| y.as_str());
let rest = captures.get(3).map(|y| y.as_str());
// Skip the SQL fast path when the expression accesses a JS runtime
// property (e.g. .length) that the PostgreSQL #> operator can't resolve.
if ends_with_js_only_property(rest) {
return None;
}
let query = if let Some(idx) = idx_o {
match rest {
Some(rest) => Some(format!("{}{}", idx, rest)),
None => Some(idx.to_string()),
}
} else {
rest.map(|x| x.trim_start_matches('.').to_string())
};
let res = authed_client
.get_result_by_id::<Option<Box<RawValue>>>(&by_id.flow_job.to_string(), obj_key, query)
.await
.ok()
.flatten();
let result = match res {
Some(v) => Ok(v),
None => serde_json::value::to_raw_value(&serde_json::Value::Null)
.map_err(|e| anyhow::anyhow!("Failed to serialize null: {}", e)),
};
return Some(result);
}
None
}
#[cfg(feature = "quickjs")]
use windmill_common::utils::unsafe_raw;
// ── QuickJS evaluation ───────────────────────────────────────────────
#[cfg(feature = "quickjs")]
/// Shared state for async operations within QuickJS
#[derive(Clone)]
struct AsyncOpState {
client: AuthedClient,
}
#[cfg(feature = "quickjs")]
pub async fn eval_timeout_quickjs(
expr: String,
transform_context: HashMap<String, Arc<Box<RawValue>>>,
flow_input: Option<mappable_rc::Marc<HashMap<String, Box<RawValue>>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
authed_client: Option<&AuthedClient>,
by_id: Option<&IdContext>,
ctx: Option<Vec<(String, String)>>,
) -> anyhow::Result<Box<RawValue>> {
let expr = expr.trim().to_string();
tracing::debug!(
"evaluating js eval (quickjs): {} with context {:?}",
expr,
transform_context
);
// Clone data for the blocking task
let by_id_clone = by_id.cloned();
let flow_input_clone = flow_input.clone();
let flow_env_clone = flow_env.cloned();
let authed_client_clone = authed_client.cloned();
// Determine which context keys are actually used in the expression
let p_ids = by_id.map(|x| {
[
format!("results.{}", x.previous_id),
format!("results?.{}", x.previous_id),
format!("results[\"{}\"]", x.previous_id),
format!("results?.[\"{}\"]", x.previous_id),
]
});
let mut context_keys: Vec<String> = transform_context
.keys()
.filter(|x| expr.contains(&x.to_string()))
.cloned()
.collect();
if (!context_keys.contains(&"previous_result".to_string())
&& p_ids.is_some()
&& p_ids.as_ref().unwrap().iter().any(|x| expr.contains(x)))
|| expr.contains("error")
{
context_keys.push("previous_result".to_string());
}
let has_flow_input = expr.contains("flow_input");
if has_flow_input {
context_keys.push("flow_input".to_string())
}
// Filter transform_context to only include used keys
let filtered_context: HashMap<String, Arc<Box<RawValue>>> = transform_context
.into_iter()
.filter(|(k, _)| context_keys.contains(k))
.collect();
let expr_clone = expr.clone();
let memory_limit = *QUICKJS_MEMORY_LIMIT_BYTES;
// Run the QuickJS evaluation with a timeout.
// Use the current runtime handle rather than creating an independent
// current-thread runtime so that HTTP connections opened by the global
// reqwest client (HTTP_CLIENT) are managed by the main runtime.
// Creating a child runtime caused connection-pool dispatch tasks to be
// dropped when the child runtime exited, poisoning pooled connections
// and producing spurious "DispatchGone" / "runtime dropped the dispatch
// task" errors on subsequent requests from the main runtime.
let handle = tokio::runtime::Handle::current();
tokio::time::timeout(
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
tokio::task::spawn_blocking(move || {
handle.block_on(async move {
eval_quickjs_inner(
&expr_clone,
filtered_context,
flow_input_clone,
flow_env_clone,
authed_client_clone,
by_id_clone,
ctx,
context_keys,
memory_limit,
)
.await
})
}),
)
.await
.map_err(|_| {
anyhow::anyhow!(
"The expression evaluation `{expr}` took too long to execute (>{EVAL_TIMEOUT_MS}ms)"
)
})??
}
/// Default memory cap, in bytes, for a single flow step-input transform eval
/// (`eval_timeout_quickjs`). Large enough for transforms that build sizeable
/// arrays; genuinely large payloads raise it via `QUICKJS_MEMORY_LIMIT_MB`.
///
/// Sizing constraint: evals are authenticated and, within a worker process, run
/// one at a time (`transform_input` awaits each transform sequentially and each
/// eval drops its runtime before the next), so in the default one-worker-per-
/// process deployment the peak is a single cap. Native / multi-worker-in-process
/// mode runs up to `NUM_WORKERS` evals concurrently in one heap, so the peak is
/// `NUM_WORKERS × cap` — hence a modest default rather than a large one.
#[cfg(feature = "quickjs")]
const DEFAULT_QUICKJS_MEMORY_LIMIT_BYTES: usize = 128 * 1024 * 1024;
/// Memory cap, in bytes, for `eval_simple_js` (batch-rerun filter and `retry_if`
/// boolean/arg expressions). Kept lower than the flow-transform cap and NOT tied
/// to `QUICKJS_MEMORY_LIMIT_MB`: `eval_simple_js` runs in the API process, which
/// serves concurrent requests with no per-process serialization, so its peak is
/// `concurrent_requests × cap` and unbounded by worker count. These expressions
/// only remap job args / return a boolean, so a small cap is ample.
#[cfg(feature = "quickjs")]
const EVAL_SIMPLE_JS_MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
#[cfg(feature = "quickjs")]
lazy_static! {
/// Flow-transform eval memory limit in bytes, resolved once at process start.
/// Overridable via the `QUICKJS_MEMORY_LIMIT_MB` env var (a positive integer
/// in MB); falls back to `DEFAULT_QUICKJS_MEMORY_LIMIT_BYTES` otherwise.
static ref QUICKJS_MEMORY_LIMIT_BYTES: usize = std::env::var("QUICKJS_MEMORY_LIMIT_MB")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|mb| *mb > 0)
.map(|mb| mb.saturating_mul(1024 * 1024))
.unwrap_or(DEFAULT_QUICKJS_MEMORY_LIMIT_BYTES);
}
/// Convert a caught QuickJS error into an `anyhow::Error`, turning heap
/// exhaustion into a clear, actionable message instead of an opaque one.
///
/// QuickJS signals OOM two ways, neither meaningful to users: an `InternalError`
/// whose message is exactly "out of memory", or — when it cannot even allocate
/// that error — a bare `null`/`undefined` throw that rquickjs renders as
/// "Exception generated by quickjs". Both are detected here from the caught value
/// itself (its kind), which is reliable; a post-hoc heap check is not, because
/// QuickJS ref-count frees the offending allocations as the JS stack unwinds.
///
/// The `InternalError` name is required (not just the message) so that a user's
/// own `throw new Error("out of memory")` — a plain `Error` — is left as a normal
/// error. The one irreducible ambiguity is an explicit `throw null` / `throw
/// undefined`: QuickJS's own OOM null-throw is indistinguishable from it, so those
/// rare user throws are intentionally absorbed into the OOM bucket rather than
/// leaking the opaque error for the far more common genuine-OOM case.
///
/// `env_override` names the env var that tunes the cap for this eval path, or is
/// `None` when the cap is fixed (so the message doesn't advise a setting that
/// wouldn't help).
#[cfg(feature = "quickjs")]
fn map_quickjs_error(
err: rquickjs::CaughtError<'_>,
memory_limit: usize,
env_override: Option<&str>,
) -> anyhow::Error {
let is_oom = match &err {
rquickjs::CaughtError::Exception(e) => {
e.as_object()
.get::<_, Option<String>>("name")
.ok()
.flatten()
.as_deref()
== Some("InternalError")
&& e.message().as_deref() == Some("out of memory")
}
rquickjs::CaughtError::Value(v) => v.is_null() || v.is_undefined(),
rquickjs::CaughtError::Error(_) => false,
};
if is_oom {
let remediation = match env_override {
Some(var) => format!(
"Reduce the amount of data handled in the expression, or raise the \
cap via the {var} environment variable."
),
None => "Reduce the amount of data handled in the expression.".to_string(),
};
anyhow::anyhow!(
"The expression evaluation exceeded the memory limit of {} MB. {}",
memory_limit / (1024 * 1024),
remediation
)
} else {
anyhow::anyhow!("QuickJS evaluation error: {}", err)
}
}
#[cfg(feature = "quickjs")]
async fn eval_quickjs_inner(
expr: &str,
transform_context: HashMap<String, Arc<Box<RawValue>>>,
flow_input: Option<mappable_rc::Marc<HashMap<String, Box<RawValue>>>>,
flow_env: Option<HashMap<String, Box<RawValue>>>,
authed_client: Option<AuthedClient>,
by_id: Option<IdContext>,
extra_ctx: Option<Vec<(String, String)>>,
context_keys: Vec<String>,
memory_limit: usize,
) -> anyhow::Result<Box<RawValue>> {
let runtime = AsyncRuntime::new()?;
runtime.set_memory_limit(memory_limit).await;
let context = AsyncContext::full(&runtime).await?;
// Create shared state for async ops if we have a client
let op_state = authed_client.map(|client| Arc::new(AsyncOpState { client }));
let op_state_clone = op_state.clone();
let by_id_clone = by_id.clone();
// Transform expression to add await for variable/resource/results access
let expr_with_funcs = ["variable", "resource", "flow_user_state"]
.into_iter()
.fold(expr.to_string(), replace_with_await);
let transformed_expr = replace_with_await_result(expr_with_funcs);
async_with!(context => |ctx| {
let globals = ctx.globals();
// Set up context variables
for key in &context_keys {
if key == "flow_input" {
if let Some(ref fi) = flow_input {
let json_str = serde_json::to_string(fi.as_ref())?;
let val: serde_json::Value = serde_json::from_str(&json_str)?;
let js_val = json_to_js(&ctx, &val)?;
globals.set(key.as_str(), js_val)?;
} else {
globals.set(key.as_str(), Value::new_null(ctx.clone()))?;
}
} else if let Some(raw_val) = transform_context.get(key) {
let val: serde_json::Value = serde_json::from_str(raw_val.get())?;
let js_val = json_to_js(&ctx, &val)?;
globals.set(key.as_str(), js_val)?;
}
}
// Set up flow_env if referenced
if expr.contains("flow_env") {
if let Some(ref fe) = flow_env {
let obj = Object::new(ctx.clone())?;
for (k, v) in fe {
let val: serde_json::Value = serde_json::from_str(v.get())?;
let js_val = json_to_js(&ctx, &val)?;
obj.set(k.as_str(), js_val)?;
}
globals.set("flow_env", obj)?;
} else {
globals.set("flow_env", Object::new(ctx.clone())?)?;
}
}
// Set up additional context variables
if let Some(ctx_vars) = extra_ctx {
for (k, v) in ctx_vars {
globals.set(k.as_str(), v.as_str())?;
}
}
// Set up error extraction if needed
if expr.contains("error") && context_keys.contains(&"previous_result".to_string()) {
let error_setup = r#"
let error = previous_result?.error;
if (!error) {
if (Array.isArray(previous_result)) {
const errors = previous_result.filter(item => item && typeof item === 'object' && 'error' in item);
if (errors.length === 1) {
error = errors[0].error;
} else if (errors.length > 1) {
error = {
name: 'MultipleErrors',
message: errors.map(({ error: e }, i) => `[${e.step_id || i}] ${e.message || e.name}`).join('; '),
errors: previous_result
};
} else {
error = {
name: 'MultipleErrors',
message: "Could not parse errors",
errors: previous_result
};
}
} else {
if (previous_result) {
error = { name: 'UnknownError', message: 'Could not parse the error', error: previous_result };
} else {
error = { name: 'UnknownError', message: 'No error found' };
}
}
}
"#;
ctx.eval::<(), _>(error_setup).catch(&ctx).map_err(quickjs_error_to_anyhow)?;
}
// Set up async functions if we have a client
if let Some(ref state) = op_state_clone {
setup_async_ops(&ctx, &globals, state.clone())?;
} else {
// Set up stub functions that throw errors
setup_stub_functions(&ctx, &globals)?;
}
// Set up results proxy if we have by_id context
if let Some(ref by_id) = by_id_clone {
setup_results_proxy(&ctx, &globals, by_id, op_state_clone.clone())?;
}
// The auto-await rewrite at the top of eval_timeout always rewrites
// `flow_user_state(...)` regardless of context, so install a stub if
// nothing above defined it (e.g. authed sleep transforms with no by_id).
ctx.eval::<(), _>(r#"
if (typeof flow_user_state === 'undefined') {
globalThis.flow_user_state = function(key) {
return Promise.reject(new Error(`flow_user_state() is not available in this context`));
};
}
"#)
.catch(&ctx)
.map_err(quickjs_error_to_anyhow)?;
// Determine if we need to add return statement.
let code = if should_add_return_quickjs(&transformed_expr) {
format!("(async function() {{ return {}; }})().then((x) => JSON.stringify(x ?? null))", transformed_expr)
} else {
format!("(async function() {{ {} }})().then((x) => JSON.stringify(x ?? null))", transformed_expr)
};
// Evaluate the expression (returns a Promise that resolves to a JSON string)
let promise: rquickjs::Promise = ctx.eval(code).catch(&ctx).map_err(|e| map_quickjs_error(e, memory_limit, Some("QUICKJS_MEMORY_LIMIT_MB")))?;
// Await the promise
let result: Value = promise.into_future().await.catch(&ctx).map_err(|e| map_quickjs_error(e, memory_limit, Some("QUICKJS_MEMORY_LIMIT_MB")))?;
let json_str = String::from_js(&ctx, result)
.unwrap_or_else(|_| "null".to_string());
Ok(unsafe_raw(json_str))
})
.await
}
#[cfg(feature = "quickjs")]
fn setup_async_ops<'js>(
ctx: &rquickjs::Ctx<'js>,
globals: &Object<'js>,
state: Arc<AsyncOpState>,
) -> anyhow::Result<()> {
const ERR_PREFIX: &str = "\x00__WINDMILL_ERR__\x00";
let state_for_var = state.clone();
globals.set(
"__fetchVariable",
Func::from(Async(MutFn::new(move |path: String| {
let client = state_for_var.client.clone();
async move {
match client.get_variable_value(&path).await {
Ok(value) => value,
Err(e) => format!("{}{}", ERR_PREFIX, e),
}
}
}))),
)?;
let state_for_res = state.clone();
globals.set(
"__fetchResource",
Func::from(Async(MutFn::new(move |path: String| {
let client = state_for_res.client.clone();
async move {
match client
.get_resource_value_interpolated::<serde_json::Value>(&path, None)
.await
{
Ok(value) => {
serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
}
Err(e) => format!("{}{}", ERR_PREFIX, e),
}
}
}))),
)?;
let wrapper_code = r#"
const __ERR_PREFIX = '\x00__WINDMILL_ERR__\x00';
async function variable(path) {
const result = await __fetchVariable(path);
if (typeof result === 'string' && result.startsWith(__ERR_PREFIX)) {
throw new Error(result.substring(__ERR_PREFIX.length));
}
return result;
}
async function resource(path) {
const result = await __fetchResource(path);
if (typeof result === 'string' && result.startsWith(__ERR_PREFIX)) {
throw new Error(result.substring(__ERR_PREFIX.length));
}
return JSON.parse(result);
}
"#;
ctx.eval::<(), _>(wrapper_code)
.catch(ctx)
.map_err(quickjs_error_to_anyhow)?;
Ok(())
}
#[cfg(feature = "quickjs")]
fn setup_stub_functions<'js>(
ctx: &rquickjs::Ctx<'js>,
_globals: &Object<'js>,
) -> anyhow::Result<()> {
let setup_code = r#"
function variable(path) {
return Promise.reject(new Error(`variable() is not available without an authenticated client`));
}
function resource(path) {
return Promise.reject(new Error(`resource() is not available without an authenticated client`));
}
function flow_user_state(key) {
return Promise.reject(new Error(`flow_user_state() is not available without an authenticated client`));
}
"#;
ctx.eval::<(), _>(setup_code)
.catch(ctx)
.map_err(quickjs_error_to_anyhow)?;
Ok(())
}
#[cfg(feature = "quickjs")]
fn setup_results_proxy<'js>(
ctx: &rquickjs::Ctx<'js>,
globals: &Object<'js>,
by_id: &IdContext,
op_state: Option<Arc<AsyncOpState>>,
) -> anyhow::Result<()> {
globals.set("__previous_id", by_id.previous_id.clone())?;
if let Some(state) = op_state {
let by_id_for_result = by_id.clone();
let state_for_user_state = state.clone();
let root_flow_job_id_for_state = by_id.root_flow_job.to_string();
globals.set(
"__fetchResult",
Func::from(Async(MutFn::new(move |step_id: String| {
let client = state.client.clone();
let by_id = by_id_for_result.clone();
let step_id_clone = step_id.clone();
let job_result = by_id.steps_results.get(&step_id).cloned();
let flow_job_id = by_id.flow_job.to_string();
async move {
const ERR_PREFIX: &str = "\x00__WINDMILL_ERR__\x00";
let result: Result<serde_json::Value, String> = match job_result {
Some(jr) => match jr {
JobResult::SingleJob(job_id) => client
.get_completed_job_result::<serde_json::Value>(
&job_id.to_string(),
None,
)
.await
.map_err(|e| {
format!(
"Failed to fetch result for step '{}': {}",
step_id_clone, e
)
}),
JobResult::ListJob(job_ids) => {
let futs = job_ids.iter().map(|job_id| {
let client = client.clone();
let job_id_str = job_id.to_string();
async move {
client
.get_completed_job_result::<serde_json::Value>(
&job_id_str,
None,
)
.await
}
});
let results: Vec<_> = futures::future::join_all(futs).await;
let collected: Result<Vec<_>, _> = results.into_iter().collect();
collected.map(serde_json::Value::Array).map_err(|e| {
format!(
"Failed to fetch results for step '{}': {}",
step_id_clone, e
)
})
}
},
None => Ok(client
.get_result_by_id::<serde_json::Value>(
&flow_job_id,
&step_id_clone,
None,
)
.await
.ok()
.unwrap_or(serde_json::Value::Null)),
};
match result {
Ok(value) => {
serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
}
Err(e) => format!("{}{}", ERR_PREFIX, e),
}
}
}))),
)?;
globals.set(
"__fetchFlowUserState",
Func::from(Async(MutFn::new(move |key: String| {
let client = state_for_user_state.client.clone();
let job_id = root_flow_job_id_for_state.clone();
async move {
const ERR_PREFIX: &str = "\x00__WINDMILL_ERR__\x00";
match client.get_flow_user_state(&job_id, &key).await {
Ok(value) => {
serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string())
}
Err(e) => format!("{}{}", ERR_PREFIX, e),
}
}
}))),
)?;
let wrapper_code = r#"
const __RESULT_ERR_PREFIX = '\x00__WINDMILL_ERR__\x00';
function __throwOrParse(result) {
if (typeof result === 'string' && result.startsWith(__RESULT_ERR_PREFIX)) {
throw new Error(result.substring(__RESULT_ERR_PREFIX.length));
}
return JSON.parse(result);
}
async function __getResult(stepId) {
return __throwOrParse(await __fetchResult(stepId));
}
async function flow_user_state(key) {
return __throwOrParse(await __fetchFlowUserState(key));
}
"#;
ctx.eval::<(), _>(wrapper_code)
.catch(ctx)
.map_err(quickjs_error_to_anyhow)?;
} else {
let stub_code = r#"
function __getResult(stepId) {
return Promise.reject(new Error('Result fetching not available without authenticated client'));
}
function flow_user_state(key) {
return Promise.reject(new Error(`flow_user_state() is not available without an authenticated client`));
}
"#;
ctx.eval::<(), _>(stub_code)
.catch(ctx)
.map_err(quickjs_error_to_anyhow)?;
}
let proxy_setup = r#"
const results = new Proxy({}, {
get: function(target, name, receiver) {
if (typeof name === 'symbol') {
return undefined;
}
if (name === __previous_id && typeof previous_result !== 'undefined') {
return Promise.resolve(previous_result);
}
return __getResult(name);
}
});
"#;
ctx.eval::<(), _>(proxy_setup)
.catch(ctx)
.map_err(quickjs_error_to_anyhow)?;
Ok(())
}
#[cfg(feature = "quickjs")]
fn json_to_js<'js>(
ctx: &rquickjs::Ctx<'js>,
val: &serde_json::Value,
) -> rquickjs::Result<Value<'js>> {
match val {
serde_json::Value::Null => Ok(Value::new_null(ctx.clone())),
serde_json::Value::Bool(b) => Ok(Value::new_bool(ctx.clone(), *b)),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
if i >= i32::MIN as i64 && i <= i32::MAX as i64 {
Ok(Value::new_int(ctx.clone(), i as i32))
} else {
Ok(Value::new_float(ctx.clone(), i as f64))
}
} else if let Some(f) = n.as_f64() {
Ok(Value::new_float(ctx.clone(), f))
} else {
Ok(Value::new_float(ctx.clone(), 0.0))
}
}
serde_json::Value::String(s) => s.clone().into_js(ctx),
serde_json::Value::Array(arr) => {
let js_arr = rquickjs::Array::new(ctx.clone())?;
for (i, item) in arr.iter().enumerate() {
js_arr.set(i, json_to_js(ctx, item)?)?;
}
Ok(js_arr.into_value())
}
serde_json::Value::Object(obj) => {
let js_obj = Object::new(ctx.clone())?;
for (k, v) in obj {
js_obj.set(k.as_str(), json_to_js(ctx, v)?)?;
}
Ok(js_obj.into_value())
}
}
}
#[cfg(feature = "quickjs")]
fn should_add_return_quickjs(expr: &str) -> bool {
let trimmed = expr.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.starts_with("return ") || trimmed.starts_with("return;") || trimmed == "return" {
return false;
}
let statement_prefixes = [
"const ",
"let ",
"var ",
"if ",
"if(",
"for ",
"for(",
"while ",
"while(",
"switch ",
"switch(",
"try ",
"try{",
"throw ",
"function ",
"class ",
"async ",
"await ",
];
for prefix in &statement_prefixes {
if trimmed.starts_with(prefix) {
return false;
}
}
if contains_semicolon_outside_strings(trimmed) {
return false;
}
true
}
#[cfg(feature = "quickjs")]
fn contains_semicolon_outside_strings(expr: &str) -> bool {
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut in_template = false;
let mut prev_char = '\0';
for ch in expr.chars() {
match ch {
'\'' if prev_char != '\\' && !in_double_quote && !in_template => {
in_single_quote = !in_single_quote;
}
'"' if prev_char != '\\' && !in_single_quote && !in_template => {
in_double_quote = !in_double_quote;
}
'`' if prev_char != '\\' && !in_single_quote && !in_double_quote => {
in_template = !in_template;
}
';' if !in_single_quote && !in_double_quote && !in_template => {
return true;
}
_ => {}
}
prev_char = ch;
}
false
}
#[cfg(feature = "quickjs")]
fn quickjs_error_to_anyhow(err: rquickjs::CaughtError<'_>) -> anyhow::Error {
anyhow::anyhow!("QuickJS evaluation error: {}", err)
}
// ── eval_simple_js for windmill-api batch rerun ──────────────────────
#[cfg(feature = "quickjs")]
pub async fn eval_simple_js(
expr: String,
globals: HashMap<String, serde_json::Value>,
) -> anyhow::Result<Box<RawValue>> {
let memory_limit = EVAL_SIMPLE_JS_MEMORY_LIMIT_BYTES;
let handle = tokio::runtime::Handle::current();
tokio::time::timeout(
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
tokio::task::spawn_blocking(move || {
handle
.block_on(async move { eval_simple_js_inner(&expr, &globals, memory_limit).await })
}),
)
.await
.map_err(|_| {
anyhow::anyhow!("The expression evaluation took too long to execute (>{EVAL_TIMEOUT_MS}ms)")
})??
}
#[cfg(feature = "quickjs")]
async fn eval_simple_js_inner(
expr: &str,
globals: &HashMap<String, serde_json::Value>,
memory_limit: usize,
) -> anyhow::Result<Box<RawValue>> {
let runtime = AsyncRuntime::new()?;
runtime.set_memory_limit(memory_limit).await;
let context = AsyncContext::full(&runtime).await?;
async_with!(context => |ctx| {
let js_globals = ctx.globals();
// Set up each named global
for (name, value) in globals {
let js_val = json_to_js(&ctx, value)?;
js_globals.set(name.as_str(), js_val)?;
}
// Wrap expression to return JSON string
let code = format!("JSON.stringify(({}) ?? null)", expr);
// Fixed cap (EVAL_SIMPLE_JS_MEMORY_LIMIT_BYTES): no env override to suggest.
let result: String = ctx.eval(code)
.catch(&ctx)
.map_err(|e| map_quickjs_error(e, memory_limit, None))?;
Ok(unsafe_raw(result))
})
.await
}
// ── Fallback stubs when quickjs is disabled ──────────────────────────
#[cfg(not(feature = "quickjs"))]
pub async fn eval_timeout_quickjs(
_expr: String,
_transform_context: HashMap<String, Arc<Box<RawValue>>>,
_flow_input: Option<mappable_rc::Marc<HashMap<String, Box<RawValue>>>>,
_flow_env: Option<&HashMap<String, Box<RawValue>>>,
_authed_client: Option<&windmill_common::client::AuthedClient>,
_by_id: Option<&IdContext>,
_ctx: Option<Vec<(String, String)>>,
) -> anyhow::Result<Box<RawValue>> {
anyhow::bail!("JavaScript expression evaluation requires the `quickjs` feature. Enable it with: cargo build --features quickjs")
}
#[cfg(not(feature = "quickjs"))]
pub async fn eval_simple_js(
_expr: String,
_globals: HashMap<String, serde_json::Value>,
) -> anyhow::Result<Box<RawValue>> {
anyhow::bail!("JavaScript expression evaluation requires the `quickjs` feature. Enable it with: cargo build --features quickjs")
}
// ── Tests ────────────────────────────────────────────────────────────
#[cfg(all(test, feature = "quickjs"))]
mod tests {
use super::*;
use serde_json::json;
use windmill_common::worker::to_raw_value;
/// Helper: evaluate an expression with a transform context and assert against expected JSON.
async fn assert_eval(
expr: &str,
ctx: HashMap<String, Arc<Box<RawValue>>>,
expected: serde_json::Value,
) {
assert_eval_full(expr, ctx, None, None, expected).await;
}
/// Helper: evaluate with full context (transform_context, flow_input, flow_env).
async fn assert_eval_full(
expr: &str,
ctx: HashMap<String, Arc<Box<RawValue>>>,
flow_input: Option<mappable_rc::Marc<HashMap<String, Box<RawValue>>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
expected: serde_json::Value,
) {
let result = eval_timeout_quickjs(
expr.to_string(),
ctx,
flow_input,
flow_env,
None,
None,
None,
)
.await
.unwrap_or_else(|e| panic!("eval_timeout_quickjs failed for '{}': {}", expr, e));
let actual: serde_json::Value = serde_json::from_str(result.get()).unwrap_or_else(|e| {
panic!(
"Failed to parse result '{}' for '{}': {}",
result.get(),
expr,
e
)
});
assert_eq!(actual, expected, "Mismatch for expression '{}'", expr);
}
// =====================================================================
// HELPER FUNCTION TESTS
// =====================================================================
#[test]
fn test_should_add_return_quickjs() {
assert!(should_add_return_quickjs("5"));
assert!(should_add_return_quickjs("x + y"));
assert!(should_add_return_quickjs("foo()"));
assert!(should_add_return_quickjs("obj.method()"));
assert!(should_add_return_quickjs("a > b ? 'yes' : 'no'"));
assert!(should_add_return_quickjs("({ key: 'value' })"));
assert!(!should_add_return_quickjs("return 5"));
assert!(!should_add_return_quickjs("return x + y"));
assert!(!should_add_return_quickjs("const x = 5"));
assert!(!should_add_return_quickjs("let y = 10"));
assert!(!should_add_return_quickjs("if (x > 5) { return x; }"));
assert!(!should_add_return_quickjs("let x = 5; x + 1"));
assert!(!should_add_return_quickjs("try { return 1; } catch(e) {}"));
}
#[test]
fn test_contains_semicolon_outside_strings() {
assert!(contains_semicolon_outside_strings("a; b"));
assert!(contains_semicolon_outside_strings("let x = 5; x + 1"));
assert!(!contains_semicolon_outside_strings("'a;b'"));
assert!(!contains_semicolon_outside_strings("\"a;b\""));
assert!(!contains_semicolon_outside_strings("`a;b`"));
assert!(!contains_semicolon_outside_strings("x + y"));
}
#[test]
fn test_replace_with_await() {
assert_eq!(
replace_with_await("variable('test')".to_string(), "variable"),
"(await variable('test'))"
);
assert_eq!(
replace_with_await("x + variable('a') + variable('b')".to_string(), "variable"),
"x + (await variable('a')) + (await variable('b'))"
);
assert_eq!(
replace_with_await("no_match".to_string(), "variable"),
"no_match"
);
}
#[test]
fn test_replace_with_await_result() {
assert_eq!(
replace_with_await_result("results.step_a".to_string()),
"(await results.step_a)"
);
assert_eq!(
replace_with_await_result("results.a + results.b".to_string()),
"(await results.a) + (await results.b)"
);
assert_eq!(
replace_with_await_result("no_results_here".to_string()),
"no_results_here"
);
}
#[test]
fn test_try_exact_property_access_flow_input_dot() {
let mut fi = HashMap::new();
fi.insert("name".to_string(), to_raw_value(&json!("hello")));
let fi = mappable_rc::Marc::new(fi);
let result = try_exact_property_access("flow_input.name", Some(&fi), None);
assert!(result.is_some());
assert_eq!(result.unwrap().get(), "\"hello\"");
}
#[test]
fn test_try_exact_property_access_flow_input_bracket() {
let mut fi = HashMap::new();
fi.insert("my_key".to_string(), to_raw_value(&json!(42)));
let fi = mappable_rc::Marc::new(fi);
let result = try_exact_property_access("flow_input[\"my_key\"]", Some(&fi), None);
assert!(result.is_some());
assert_eq!(result.unwrap().get(), "42");
}
#[test]
fn test_try_exact_property_access_flow_env() {
let mut fe = HashMap::new();
fe.insert("ENV".to_string(), to_raw_value(&json!("production")));
let result = try_exact_property_access("flow_env.ENV", None, Some(&fe));
assert!(result.is_some());
assert_eq!(result.unwrap().get(), "\"production\"");
}
#[test]
fn test_try_exact_property_access_missing_key() {
let fi = mappable_rc::Marc::new(HashMap::new());
let result = try_exact_property_access("flow_input.missing", Some(&fi), None);
assert!(result.is_none());
}
#[test]
fn test_try_exact_property_access_no_prefix() {
let result = try_exact_property_access("some_var.key", None, None);
assert!(result.is_none());
}
// =====================================================================
// SIMPLE ARITHMETIC
// =====================================================================
#[tokio::test]
async fn test_simple_arithmetic() {
let mut env = HashMap::new();
env.insert("x".to_string(), Arc::new(to_raw_value(&json!(5))));
env.insert("y".to_string(), Arc::new(to_raw_value(&json!(3))));
assert_eval("x + y", env.clone(), json!(8)).await;
assert_eval("x - y", env.clone(), json!(2)).await;
assert_eval("x * y", env.clone(), json!(15)).await;
assert_eval("x % y", env.clone(), json!(2)).await;
assert_eval("x ** 2", env.clone(), json!(25)).await;
}
// =====================================================================
// OBJECT PROPERTY ACCESS
// =====================================================================
#[tokio::test]
async fn test_object_property_access() {
let mut env = HashMap::new();
env.insert(
"obj".to_string(),
Arc::new(to_raw_value(&json!({
"name": "test",
"value": 42,
"nested": {"deep": {"property": "found"}}
}))),
);
assert_eval("obj.name", env.clone(), json!("test")).await;
assert_eval("obj.value", env.clone(), json!(42)).await;
assert_eval("obj.nested.deep.property", env.clone(), json!("found")).await;
assert_eval("obj['name']", env.clone(), json!("test")).await;
}
// =====================================================================
// ARRAY OPERATIONS
// =====================================================================
#[tokio::test]
async fn test_array_operations() {
let mut env = HashMap::new();
env.insert(
"arr".to_string(),
Arc::new(to_raw_value(&json!([1, 2, 3, 4, 5]))),
);
assert_eval("arr.length", env.clone(), json!(5)).await;
assert_eval("arr[0]", env.clone(), json!(1)).await;
assert_eval("arr.map(x => x * 2)", env.clone(), json!([2, 4, 6, 8, 10])).await;
assert_eval("arr.filter(x => x > 2)", env.clone(), json!([3, 4, 5])).await;
assert_eval("arr.reduce((a, b) => a + b, 0)", env.clone(), json!(15)).await;
assert_eval("arr.find(x => x > 3)", env.clone(), json!(4)).await;
assert_eval("arr.some(x => x > 4)", env.clone(), json!(true)).await;
assert_eval("arr.every(x => x > 0)", env.clone(), json!(true)).await;
}
// =====================================================================
// STRING OPERATIONS
// =====================================================================
#[tokio::test]
async fn test_string_operations() {
let mut env = HashMap::new();
env.insert(
"s".to_string(),
Arc::new(to_raw_value(&json!("Hello World"))),
);
assert_eval("s.toLowerCase()", env.clone(), json!("hello world")).await;
assert_eval("s.toUpperCase()", env.clone(), json!("HELLO WORLD")).await;
assert_eval("s.length", env.clone(), json!(11)).await;
assert_eval("s.split(' ')", env.clone(), json!(["Hello", "World"])).await;
assert_eval(
"s.replace('World', 'QuickJS')",
env.clone(),
json!("Hello QuickJS"),
)
.await;
assert_eval("s.includes('World')", env.clone(), json!(true)).await;
assert_eval("s.startsWith('Hello')", env.clone(), json!(true)).await;
assert_eval("s.trim()", env.clone(), json!("Hello World")).await;
}
// =====================================================================
// TERNARY AND CONDITIONALS
// =====================================================================
#[tokio::test]
async fn test_ternary_and_conditionals() {
let mut env = HashMap::new();
env.insert("x".to_string(), Arc::new(to_raw_value(&json!(10))));
env.insert("y".to_string(), Arc::new(to_raw_value(&json!(5))));
assert_eval("x > y ? 'bigger' : 'smaller'", env.clone(), json!("bigger")).await;
assert_eval("x === 10 ? true : false", env.clone(), json!(true)).await;
assert_eval("x > 5 && y < 10", env.clone(), json!(true)).await;
assert_eval("x > 20 || y < 10", env.clone(), json!(true)).await;
assert_eval("!false", env.clone(), json!(true)).await;
}
// =====================================================================
// OBJECT CREATION
// =====================================================================
#[tokio::test]
async fn test_object_creation() {
let mut env = HashMap::new();
env.insert("name".to_string(), Arc::new(to_raw_value(&json!("test"))));
env.insert("value".to_string(), Arc::new(to_raw_value(&json!(42))));
assert_eval("({ foo: 'bar' })", env.clone(), json!({"foo": "bar"})).await;
assert_eval(
"({ name, value })",
env.clone(),
json!({"name": "test", "value": 42}),
)
.await;
assert_eval(
"({ ...{ a: 1 }, b: 2 })",
env.clone(),
json!({"a": 1, "b": 2}),
)
.await;
}
// =====================================================================
// NULL / UNDEFINED
// =====================================================================
#[tokio::test]
async fn test_null_undefined() {
assert_eval("null", HashMap::new(), json!(null)).await;
assert_eval("undefined", HashMap::new(), json!(null)).await;
let mut env = HashMap::new();
env.insert("x".to_string(), Arc::new(to_raw_value(&json!(null))));
assert_eval("x", env.clone(), json!(null)).await;
assert_eval("x ?? 'default'", env.clone(), json!("default")).await;
}
// =====================================================================
// FLOW INPUT
// =====================================================================
#[tokio::test]
async fn test_flow_input() {
let mut fi = HashMap::new();
fi.insert("name".to_string(), to_raw_value(&json!("test_flow")));
fi.insert("count".to_string(), to_raw_value(&json!(100)));
fi.insert(
"config".to_string(),
to_raw_value(&json!({"enabled": true})),
);
let fi = Some(mappable_rc::Marc::new(fi));
assert_eval_full(
"flow_input.name",
HashMap::new(),
fi.clone(),
None,
json!("test_flow"),
)
.await;
assert_eval_full(
"flow_input.count",
HashMap::new(),
fi.clone(),
None,
json!(100),
)
.await;
assert_eval_full(
"flow_input.config.enabled",
HashMap::new(),
fi.clone(),
None,
json!(true),
)
.await;
}
// =====================================================================
// TEMPLATE LITERALS
// =====================================================================
#[tokio::test]
async fn test_template_literals() {
let mut env = HashMap::new();
env.insert("name".to_string(), Arc::new(to_raw_value(&json!("World"))));
env.insert("x".to_string(), Arc::new(to_raw_value(&json!(5))));
assert_eval("`Hello ${name}!`", env.clone(), json!("Hello World!")).await;
assert_eval(
"`The answer is ${x * 2}`",
env.clone(),
json!("The answer is 10"),
)
.await;
}
// =====================================================================
// JSON OPERATIONS
// =====================================================================
#[tokio::test]
async fn test_json_operations() {
let mut env = HashMap::new();
env.insert(
"obj".to_string(),
Arc::new(to_raw_value(&json!({"a": 1, "b": 2}))),
);
// JSON.stringify produces a string result
assert_eval(
"JSON.stringify(obj)",
env.clone(),
json!("{\"a\":1,\"b\":2}"),
)
.await;
assert_eval("Object.keys(obj)", env.clone(), json!(["a", "b"])).await;
assert_eval("Object.values(obj)", env.clone(), json!([1, 2])).await;
}
// =====================================================================
// MATH OPERATIONS
// =====================================================================
#[tokio::test]
async fn test_math_operations() {
let env = HashMap::new();
assert_eval("Math.max(1, 5, 3)", env.clone(), json!(5)).await;
assert_eval("Math.min(1, 5, 3)", env.clone(), json!(1)).await;
assert_eval("Math.abs(-5)", env.clone(), json!(5)).await;
assert_eval("Math.floor(3.7)", env.clone(), json!(3)).await;
assert_eval("Math.ceil(3.2)", env.clone(), json!(4)).await;
assert_eval("Math.round(3.5)", env.clone(), json!(4)).await;
}
// =====================================================================
// TYPE COERCION
// =====================================================================
#[tokio::test]
async fn test_type_coercion() {
let mut env = HashMap::new();
env.insert("num".to_string(), Arc::new(to_raw_value(&json!(42))));
env.insert("str".to_string(), Arc::new(to_raw_value(&json!("123"))));
assert_eval("String(num)", env.clone(), json!("42")).await;
assert_eval("Number(str)", env.clone(), json!(123)).await;
assert_eval("Boolean(num)", env.clone(), json!(true)).await;
assert_eval("parseInt('42px')", env.clone(), json!(42)).await;
assert_eval("parseFloat('3.14')", env.clone(), json!(3.14)).await;
}
// =====================================================================
// ARRAY SPREAD
// =====================================================================
#[tokio::test]
async fn test_array_spread() {
let mut env = HashMap::new();
env.insert(
"arr1".to_string(),
Arc::new(to_raw_value(&json!([1, 2, 3]))),
);
env.insert(
"arr2".to_string(),
Arc::new(to_raw_value(&json!([4, 5, 6]))),
);
assert_eval("[...arr1, ...arr2]", env.clone(), json!([1, 2, 3, 4, 5, 6])).await;
assert_eval("[0, ...arr1, 99]", env.clone(), json!([0, 1, 2, 3, 99])).await;
}
// =====================================================================
// MULTILINE STATEMENTS
// =====================================================================
#[tokio::test]
async fn test_multiline_statements() {
let mut env = HashMap::new();
env.insert("x".to_string(), Arc::new(to_raw_value(&json!(5))));
assert_eval(
r#"let y = x * 2;
return y + 1"#,
env.clone(),
json!(11),
)
.await;
assert_eval(
r#"const result = x > 3 ? 'big' : 'small';
return result"#,
env.clone(),
json!("big"),
)
.await;
}
// =====================================================================
// OPTIONAL CHAINING & NULLISH COALESCING
// =====================================================================
#[tokio::test]
async fn test_optional_chaining_nullish() {
let mut env = HashMap::new();
env.insert(
"obj".to_string(),
Arc::new(to_raw_value(&json!({"a": {"b": 1}}))),
);
env.insert("empty".to_string(), Arc::new(to_raw_value(&json!(null))));
assert_eval("obj?.a?.b", env.clone(), json!(1)).await;
assert_eval("obj?.a?.c", env.clone(), json!(null)).await;
assert_eval("obj?.x?.y", env.clone(), json!(null)).await;
assert_eval("empty?.foo", env.clone(), json!(null)).await;
assert_eval("null ?? 'default'", env.clone(), json!("default")).await;
assert_eval("undefined ?? 'default'", env.clone(), json!("default")).await;
assert_eval("0 ?? 'default'", env.clone(), json!(0)).await;
assert_eval("'' ?? 'default'", env.clone(), json!("")).await;
assert_eval("false ?? 'default'", env.clone(), json!(false)).await;
}
// =====================================================================
// DESTRUCTURING
// =====================================================================
#[tokio::test]
async fn test_destructuring() {
let mut env = HashMap::new();
env.insert(
"obj".to_string(),
Arc::new(to_raw_value(&json!({"name": "test", "value": 42}))),
);
env.insert(
"arr".to_string(),
Arc::new(to_raw_value(&json!([1, 2, 3, 4, 5]))),
);
assert_eval(
"const { name, value } = obj; return { name, value }",
env.clone(),
json!({"name": "test", "value": 42}),
)
.await;
assert_eval(
"const [first, second, ...rest] = arr; return { first, second, rest }",
env.clone(),
json!({"first": 1, "second": 2, "rest": [3, 4, 5]}),
)
.await;
assert_eval(
"const { missing = 'default' } = obj; return missing",
env.clone(),
json!("default"),
)
.await;
}
// =====================================================================
// NUMBER EDGE CASES
// =====================================================================
#[tokio::test]
async fn test_number_edge_cases() {
let env = HashMap::new();
assert_eval(
"Number.MAX_SAFE_INTEGER",
env.clone(),
json!(9007199254740991_i64),
)
.await;
assert_eval(
"Number.MIN_SAFE_INTEGER",
env.clone(),
json!(-9007199254740991_i64),
)
.await;
assert_eval("Number.isInteger(5)", env.clone(), json!(true)).await;
assert_eval("Number.isInteger(5.5)", env.clone(), json!(false)).await;
assert_eval("Number.isFinite(Infinity)", env.clone(), json!(false)).await;
assert_eval("Number.isNaN(NaN)", env.clone(), json!(true)).await;
assert_eval("isNaN(NaN)", env.clone(), json!(true)).await;
assert_eval("isFinite(100)", env.clone(), json!(true)).await;
}
// =====================================================================
// REGEX BASIC
// =====================================================================
#[tokio::test]
async fn test_regex_basic() {
let mut env = HashMap::new();
env.insert(
"str".to_string(),
Arc::new(to_raw_value(&json!("hello world 123"))),
);
assert_eval("/hello/.test(str)", env.clone(), json!(true)).await;
assert_eval("str.match(/\\d+/)?.[0]", env.clone(), json!("123")).await;
assert_eval(
"str.replace(/world/, 'universe')",
env.clone(),
json!("hello universe 123"),
)
.await;
assert_eval(
"str.split(/\\s+/)",
env.clone(),
json!(["hello", "world", "123"]),
)
.await;
assert_eval("'aaa'.replace(/a/g, 'b')", env.clone(), json!("bbb")).await;
assert_eval("/HELLO/i.test(str)", env.clone(), json!(true)).await;
}
// =====================================================================
// DATE OPERATIONS
// =====================================================================
#[tokio::test]
async fn test_date_basic() {
let env = HashMap::new();
assert_eval(
"Date.parse('2024-01-15T00:00:00.000Z')",
env.clone(),
json!(1705276800000_i64),
)
.await;
assert_eval(
"new Date('2024-01-15T00:00:00.000Z').getUTCFullYear()",
env.clone(),
json!(2024),
)
.await;
assert_eval(
"new Date('2024-01-15T00:00:00.000Z').getUTCMonth()",
env.clone(),
json!(0),
)
.await;
assert_eval(
"new Date('2024-01-15T00:00:00.000Z').getUTCDate()",
env.clone(),
json!(15),
)
.await;
assert_eval(
"new Date('2024-01-15T00:00:00.000Z').toISOString()",
env.clone(),
json!("2024-01-15T00:00:00.000Z"),
)
.await;
}
#[tokio::test]
async fn test_date_serialization() {
let env = HashMap::new();
// Direct Date → ISO string via toJSON
assert_eval(
"new Date('2024-01-15T12:30:00.000Z')",
env.clone(),
json!("2024-01-15T12:30:00.000Z"),
)
.await;
// Date within an object
assert_eval(
"({ date: new Date('2024-01-15T00:00:00.000Z'), name: 'test' })",
env.clone(),
json!({"date": "2024-01-15T00:00:00.000Z", "name": "test"}),
)
.await;
// Deeply nested Date
assert_eval(
"({ level1: { level2: { date: new Date('2024-01-15T00:00:00.000Z') } } })",
env.clone(),
json!({"level1": {"level2": {"date": "2024-01-15T00:00:00.000Z"}}}),
)
.await;
}
// =====================================================================
// SPECIAL OBJECT SERIALIZATION
// =====================================================================
#[tokio::test]
async fn test_special_object_serialization() {
let env = HashMap::new();
// RegExp, Map, Set all serialize to {}
assert_eval("/test/gi", env.clone(), json!({})).await;
assert_eval("new Map([['key', 'value']])", env.clone(), json!({})).await;
assert_eval("new Set([1, 2, 3])", env.clone(), json!({})).await;
}
// =====================================================================
// ARRAY ADVANCED
// =====================================================================
#[tokio::test]
async fn test_array_advanced() {
let mut env = HashMap::new();
env.insert(
"arr".to_string(),
Arc::new(to_raw_value(&json!([3, 1, 4, 1, 5, 9, 2, 6]))),
);
env.insert(
"nested".to_string(),
Arc::new(to_raw_value(&json!([[1, 2], [3, 4], [5, 6]]))),
);
assert_eval(
"[...arr].sort((a, b) => a - b)",
env.clone(),
json!([1, 1, 2, 3, 4, 5, 6, 9]),
)
.await;
assert_eval(
"[...arr].sort((a, b) => b - a)",
env.clone(),
json!([9, 6, 5, 4, 3, 2, 1, 1]),
)
.await;
assert_eval("nested.flat()", env.clone(), json!([1, 2, 3, 4, 5, 6])).await;
assert_eval(
"nested.flatMap(x => x)",
env.clone(),
json!([1, 2, 3, 4, 5, 6]),
)
.await;
assert_eval("arr.indexOf(5)", env.clone(), json!(4)).await;
assert_eval("arr.indexOf(99)", env.clone(), json!(-1)).await;
assert_eval("arr.includes(9)", env.clone(), json!(true)).await;
assert_eval("arr.slice(2, 5)", env.clone(), json!([4, 1, 5])).await;
assert_eval("arr.slice(-3)", env.clone(), json!([9, 2, 6])).await;
}
// =====================================================================
// LOGICAL OPERATORS
// =====================================================================
#[tokio::test]
async fn test_logical_operators() {
let mut env = HashMap::new();
env.insert("a".to_string(), Arc::new(to_raw_value(&json!(true))));
env.insert("b".to_string(), Arc::new(to_raw_value(&json!(false))));
env.insert("x".to_string(), Arc::new(to_raw_value(&json!(5))));
assert_eval("a && 'yes'", env.clone(), json!("yes")).await;
assert_eval("b && 'yes'", env.clone(), json!(false)).await;
assert_eval("b || 'no'", env.clone(), json!("no")).await;
assert_eval("a || 'no'", env.clone(), json!(true)).await;
assert_eval("let y = null; y ??= 10; return y", env.clone(), json!(10)).await;
assert_eval("let y = 5; y ??= 10; return y", env.clone(), json!(5)).await;
assert_eval("(a && x > 3) || (b && x < 3)", env.clone(), json!(true)).await;
}
// =====================================================================
// TYPEOF
// =====================================================================
#[tokio::test]
async fn test_typeof() {
let mut env = HashMap::new();
env.insert("str".to_string(), Arc::new(to_raw_value(&json!("hello"))));
env.insert("num".to_string(), Arc::new(to_raw_value(&json!(42))));
env.insert("arr".to_string(), Arc::new(to_raw_value(&json!([1, 2, 3]))));
env.insert("obj".to_string(), Arc::new(to_raw_value(&json!({"a": 1}))));
env.insert("n".to_string(), Arc::new(to_raw_value(&json!(null))));
assert_eval("typeof str", env.clone(), json!("string")).await;
assert_eval("typeof num", env.clone(), json!("number")).await;
assert_eval("typeof arr", env.clone(), json!("object")).await;
assert_eval("typeof obj", env.clone(), json!("object")).await;
assert_eval("typeof n", env.clone(), json!("object")).await;
assert_eval("typeof undefined", env.clone(), json!("undefined")).await;
assert_eval("Array.isArray(arr)", env.clone(), json!(true)).await;
assert_eval("Array.isArray(obj)", env.clone(), json!(false)).await;
}
// =====================================================================
// COMPLEX MULTILINE EXPRESSIONS
// =====================================================================
#[tokio::test]
async fn test_multiline_complex_logic() {
let mut env = HashMap::new();
env.insert(
"users".to_string(),
Arc::new(to_raw_value(&json!([
{"name": "Alice", "age": 30, "role": "admin"},
{"name": "Bob", "age": 25, "role": "user"},
{"name": "Charlie", "age": 35, "role": "admin"},
{"name": "Diana", "age": 28, "role": "user"}
]))),
);
assert_eval(
r#"
const admins = users.filter(u => u.role === 'admin');
const names = admins.map(u => u.name);
return names.join(', ')
"#,
env.clone(),
json!("Alice, Charlie"),
)
.await;
assert_eval(
r#"
const totalAge = users.reduce((sum, u) => sum + u.age, 0);
const avgAge = totalAge / users.length;
return Math.round(avgAge)
"#,
env.clone(),
json!(30),
)
.await;
assert_eval(
r#"
const grouped = users.reduce((acc, u) => {
if (!acc[u.role]) acc[u.role] = [];
acc[u.role].push(u.name);
return acc;
}, {});
return grouped
"#,
env.clone(),
json!({"admin": ["Alice", "Charlie"], "user": ["Bob", "Diana"]}),
)
.await;
}
// =====================================================================
// DATA TRANSFORMATION
// =====================================================================
#[tokio::test]
async fn test_multiline_data_transformation() {
let mut env = HashMap::new();
env.insert(
"data".to_string(),
Arc::new(to_raw_value(&json!({
"items": [
{"id": 1, "price": 100, "quantity": 2},
{"id": 2, "price": 50, "quantity": 5},
{"id": 3, "price": 75, "quantity": 3}
],
"discount": 0.1
}))),
);
assert_eval(
r#"
const subtotals = data.items.map(item => item.price * item.quantity);
const total = subtotals.reduce((a, b) => a + b, 0);
const discounted = total * (1 - data.discount);
return { subtotals, total, discounted }
"#,
env.clone(),
json!({"subtotals": [200, 250, 225], "total": 675, "discounted": 607.5}),
)
.await;
}
// =====================================================================
// TRY-CATCH
// =====================================================================
#[tokio::test]
async fn test_try_catch() {
let env = HashMap::new();
assert_eval(
r#"
try {
return JSON.parse('{"valid": true}');
} catch (e) {
return { problem: e.message };
}
"#,
env.clone(),
json!({"valid": true}),
)
.await;
assert_eval(
r#"
try {
return JSON.parse('invalid json');
} catch (e) {
return { problem: 'parse_failed' };
}
"#,
env.clone(),
json!({"problem": "parse_failed"}),
)
.await;
assert_eval(
r#"
let result = 'initial';
try {
result = 'try';
} catch (e) {
result = 'catch';
} finally {
result = result + '_finally';
}
return result
"#,
env.clone(),
json!("try_finally"),
)
.await;
}
// =====================================================================
// OBJECT ADVANCED
// =====================================================================
#[tokio::test]
async fn test_object_advanced() {
let mut env = HashMap::new();
env.insert(
"config".to_string(),
Arc::new(to_raw_value(&json!({
"server": {"host": "localhost", "port": 8080},
"database": {"host": "db.local", "port": 5432},
"features": ["auth", "logging", "cache"]
}))),
);
assert_eval(
"Object.assign({}, config.server, { secure: true })",
env.clone(),
json!({"host": "localhost", "port": 8080, "secure": true}),
)
.await;
assert_eval(
"({ ...config.server, port: 443, secure: true })",
env.clone(),
json!({"host": "localhost", "port": 443, "secure": true}),
)
.await;
assert_eval(
"JSON.parse(JSON.stringify(config))",
env.clone(),
json!({
"server": {"host": "localhost", "port": 8080},
"database": {"host": "db.local", "port": 5432},
"features": ["auth", "logging", "cache"]
}),
)
.await;
assert_eval(
r#"
const key = 'dynamic';
return { [key]: 'value', [`${key}_2`]: 'value2' }
"#,
env.clone(),
json!({"dynamic": "value", "dynamic_2": "value2"}),
)
.await;
}
// =====================================================================
// STRING ADVANCED
// =====================================================================
#[tokio::test]
async fn test_string_advanced() {
let mut env = HashMap::new();
env.insert(
"text".to_string(),
Arc::new(to_raw_value(&json!(" Hello, World! "))),
);
env.insert(
"path".to_string(),
Arc::new(to_raw_value(&json!("/api/v1/users/123/profile"))),
);
assert_eval("text.trim()", env.clone(), json!("Hello, World!")).await;
assert_eval("text.trimStart()", env.clone(), json!("Hello, World! ")).await;
assert_eval("text.trimEnd()", env.clone(), json!(" Hello, World!")).await;
assert_eval("'42'.padStart(5, '0')", env.clone(), json!("00042")).await;
assert_eval("'42'.padEnd(5, '-')", env.clone(), json!("42---")).await;
assert_eval("'ab'.repeat(3)", env.clone(), json!("ababab")).await;
assert_eval(
"path.split('/').filter(p => p.length > 0)",
env.clone(),
json!(["api", "v1", "users", "123", "profile"]),
)
.await;
}
// =====================================================================
// ARRAY MANIPULATION
// =====================================================================
#[tokio::test]
async fn test_array_manipulation() {
let mut env = HashMap::new();
env.insert(
"items".to_string(),
Arc::new(to_raw_value(&json!([
{"id": 1, "name": "Apple", "category": "fruit"},
{"id": 2, "name": "Carrot", "category": "vegetable"},
{"id": 3, "name": "Banana", "category": "fruit"},
{"id": 4, "name": "Broccoli", "category": "vegetable"}
]))),
);
assert_eval(
"items.find(i => i.name === 'Banana')",
env.clone(),
json!({"id": 3, "name": "Banana", "category": "fruit"}),
)
.await;
assert_eval(
"items.findIndex(i => i.name === 'Banana')",
env.clone(),
json!(2),
)
.await;
assert_eval(
"items.filter(i => i.category === 'fruit').map(i => i.name).sort()",
env.clone(),
json!(["Apple", "Banana"]),
)
.await;
assert_eval(
"Array.from({length: 5}, (_, i) => i * 2)",
env.clone(),
json!([0, 2, 4, 6, 8]),
)
.await;
assert_eval("Array(3).fill(0)", env.clone(), json!([0, 0, 0])).await;
assert_eval(
"[1, 2].concat([3, 4], [5, 6])",
env.clone(),
json!([1, 2, 3, 4, 5, 6]),
)
.await;
assert_eval(
"items.map(i => i.name).join(' | ')",
env.clone(),
json!("Apple | Carrot | Banana | Broccoli"),
)
.await;
}
// =====================================================================
// SET AND MAP OPERATIONS
// =====================================================================
#[tokio::test]
async fn test_set_operations() {
let mut env = HashMap::new();
env.insert(
"arr".to_string(),
Arc::new(to_raw_value(&json!([1, 2, 2, 3, 3, 3, 4]))),
);
assert_eval("[...new Set(arr)]", env.clone(), json!([1, 2, 3, 4])).await;
assert_eval("new Set(arr).size", env.clone(), json!(4)).await;
assert_eval("new Set(arr).has(3)", env.clone(), json!(true)).await;
assert_eval("new Set(arr).has(99)", env.clone(), json!(false)).await;
}
#[tokio::test]
async fn test_map_operations() {
let env = HashMap::new();
assert_eval(
r#"
const map = new Map([['a', 1], ['b', 2], ['c', 3]]);
return Object.fromEntries(map)
"#,
env.clone(),
json!({"a": 1, "b": 2, "c": 3}),
)
.await;
assert_eval(
r#"
const map = new Map();
map.set('key1', 'value1');
map.set('key2', 'value2');
return map.get('key1')
"#,
env.clone(),
json!("value1"),
)
.await;
assert_eval(
r#"
const map = new Map([['a', 1], ['b', 2]]);
return map.size
"#,
env.clone(),
json!(2),
)
.await;
}
// =====================================================================
// COMPARISONS
// =====================================================================
#[tokio::test]
async fn test_comparisons() {
let env = HashMap::new();
assert_eval("1 === 1", env.clone(), json!(true)).await;
assert_eval("1 === '1'", env.clone(), json!(false)).await;
assert_eval("null === undefined", env.clone(), json!(false)).await;
assert_eval("null === null", env.clone(), json!(true)).await;
assert_eval("1 == '1'", env.clone(), json!(true)).await;
assert_eval("null == undefined", env.clone(), json!(true)).await;
assert_eval("5 !== '5'", env.clone(), json!(true)).await;
assert_eval("5 > 3", env.clone(), json!(true)).await;
assert_eval("5 >= 5", env.clone(), json!(true)).await;
assert_eval("3 < 5", env.clone(), json!(true)).await;
assert_eval("5 <= 5", env.clone(), json!(true)).await;
assert_eval("'apple' < 'banana'", env.clone(), json!(true)).await;
}
// =====================================================================
// BITWISE OPERATIONS
// =====================================================================
#[tokio::test]
async fn test_bitwise() {
let env = HashMap::new();
assert_eval("5 & 3", env.clone(), json!(1)).await;
assert_eval("5 | 3", env.clone(), json!(7)).await;
assert_eval("5 ^ 3", env.clone(), json!(6)).await;
assert_eval("~5", env.clone(), json!(-6)).await;
assert_eval("5 << 2", env.clone(), json!(20)).await;
assert_eval("20 >> 2", env.clone(), json!(5)).await;
}
// =====================================================================
// REAL-WORLD FLOW PATTERNS
// =====================================================================
#[tokio::test]
async fn test_flow_patterns_api_response() {
let mut env = HashMap::new();
env.insert(
"previous_result".to_string(),
Arc::new(to_raw_value(&json!({
"status": 200,
"data": {
"users": [
{"id": 1, "email": "alice@example.com", "active": true},
{"id": 2, "email": "bob@example.com", "active": false},
{"id": 3, "email": "charlie@example.com", "active": true}
],
"pagination": {"page": 1, "total": 50, "per_page": 10}
}
}))),
);
assert_eval(
"previous_result.data.users.filter(u => u.active).map(u => u.email)",
env.clone(),
json!(["alice@example.com", "charlie@example.com"]),
)
.await;
assert_eval(
r#"
const { page, total, per_page } = previous_result.data.pagination;
return page * per_page < total
"#,
env.clone(),
json!(true),
)
.await;
}
#[tokio::test]
async fn test_flow_patterns_batch_processing() {
let mut env = HashMap::new();
env.insert(
"jobs".to_string(),
Arc::new(to_raw_value(&json!([
{"id": 1, "status": "completed", "result": 100},
{"id": 2, "status": "failed", "reason": "timeout"},
{"id": 3, "status": "completed", "result": 200},
{"id": 4, "status": "failed", "reason": "connection"},
{"id": 5, "status": "completed", "result": 150}
]))),
);
assert_eval(
r#"
const completed = jobs.filter(j => j.status === 'completed');
const failed = jobs.filter(j => j.status === 'failed');
const totalResult = completed.reduce((sum, j) => sum + j.result, 0);
return {
totalJobs: jobs.length,
completedCount: completed.length,
failedCount: failed.length,
successRate: completed.length / jobs.length,
totalResult,
failureReasons: failed.map(j => j.reason)
}
"#,
env.clone(),
json!({
"totalJobs": 5,
"completedCount": 3,
"failedCount": 2,
"successRate": 0.6,
"totalResult": 450,
"failureReasons": ["timeout", "connection"]
}),
)
.await;
}
#[tokio::test]
async fn test_flow_patterns_config_merge() {
let mut env = HashMap::new();
env.insert(
"defaults".to_string(),
Arc::new(to_raw_value(&json!({
"timeout": 5000,
"retries": 3,
"headers": {"Content-Type": "application/json"},
"features": {"logging": true, "caching": false}
}))),
);
env.insert(
"overrides".to_string(),
Arc::new(to_raw_value(&json!({
"timeout": 10000,
"headers": {"Authorization": "Bearer token"},
"features": {"caching": true}
}))),
);
assert_eval(
r#"({
...defaults,
...overrides,
headers: { ...defaults.headers, ...overrides.headers },
features: { ...defaults.features, ...overrides.features }
})"#,
env.clone(),
json!({
"timeout": 10000,
"retries": 3,
"headers": {"Content-Type": "application/json", "Authorization": "Bearer token"},
"features": {"logging": true, "caching": true}
}),
)
.await;
}
// =====================================================================
// FLOW SIMULATION: Multi-step flow with various step results
// =====================================================================
fn create_multi_step_flow_context() -> (
HashMap<String, Arc<Box<RawValue>>>,
Option<mappable_rc::Marc<HashMap<String, Box<RawValue>>>>,
HashMap<String, Box<RawValue>>,
) {
let mut ctx = HashMap::new();
ctx.insert("a".to_string(), Arc::new(to_raw_value(&json!(42))));
ctx.insert(
"b".to_string(),
Arc::new(to_raw_value(&json!({
"status": "success",
"data": {
"users": [
{"id": 1, "name": "Alice", "active": true, "roles": ["admin", "user"]},
{"id": 2, "name": "Bob", "active": false, "roles": ["user"]},
{"id": 3, "name": "Charlie", "active": true, "roles": ["moderator", "user"]}
],
"total": 3,
"metadata": {"page": 1, "hasMore": true}
}
}))),
);
ctx.insert(
"c".to_string(),
Arc::new(to_raw_value(&json!([10, 20, 30, 40, 50]))),
);
ctx.insert("d".to_string(), Arc::new(to_raw_value(&json!(null))));
ctx.insert(
"f".to_string(),
Arc::new(to_raw_value(&json!({
"level1": {"level2": {"level3": {"level4": {"value": "deeply_nested"}}}}
}))),
);
ctx.insert(
"previous_result".to_string(),
Arc::new(to_raw_value(&json!([
"string", 123, true, null, {"key": "value"}, [1, 2, 3]
]))),
);
let mut fi = HashMap::new();
fi.insert("name".to_string(), to_raw_value(&json!("test_flow")));
fi.insert("count".to_string(), to_raw_value(&json!(100)));
fi.insert("enabled".to_string(), to_raw_value(&json!(true)));
fi.insert(
"config".to_string(),
to_raw_value(&json!({
"timeout": 30, "retries": 3, "options": ["fast", "secure"]
})),
);
fi.insert(
"items".to_string(),
to_raw_value(&json!([
{"id": 1, "value": "first"},
{"id": 2, "value": "second"},
{"id": 3, "value": "third"}
])),
);
let mut fe = HashMap::new();
fe.insert("ENV".to_string(), to_raw_value(&json!("production")));
fe.insert("DEBUG".to_string(), to_raw_value(&json!(false)));
fe.insert("VERSION".to_string(), to_raw_value(&json!("1.2.3")));
(ctx, Some(mappable_rc::Marc::new(fi)), fe)
}
#[tokio::test]
async fn test_flow_step_references() {
let (ctx, fi, fe) = create_multi_step_flow_context();
assert_eval_full("a", ctx.clone(), fi.clone(), Some(&fe), json!(42)).await;
assert_eval_full(
"b.status",
ctx.clone(),
fi.clone(),
Some(&fe),
json!("success"),
)
.await;
assert_eval_full("b.data.total", ctx.clone(), fi.clone(), Some(&fe), json!(3)).await;
assert_eval_full(
"b.data.users[0].name",
ctx.clone(),
fi.clone(),
Some(&fe),
json!("Alice"),
)
.await;
assert_eval_full("c[0]", ctx.clone(), fi.clone(), Some(&fe), json!(10)).await;
assert_eval_full(
"c.map(x => x * 2)",
ctx.clone(),
fi.clone(),
Some(&fe),
json!([20, 40, 60, 80, 100]),
)
.await;
assert_eval_full(
"f.level1.level2.level3.level4.value",
ctx.clone(),
fi.clone(),
Some(&fe),
json!("deeply_nested"),
)
.await;
}
#[tokio::test]
async fn test_flow_input_nested_and_combined() {
let (ctx, fi, fe) = create_multi_step_flow_context();
assert_eval_full(
"flow_input.name",
ctx.clone(),
fi.clone(),
Some(&fe),
json!("test_flow"),
)
.await;
assert_eval_full(
"flow_input.config.timeout",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(30),
)
.await;
assert_eval_full(
"flow_input.config.options[0]",
ctx.clone(),
fi.clone(),
Some(&fe),
json!("fast"),
)
.await;
assert_eval_full(
"flow_input.items.length",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(3),
)
.await;
assert_eval_full(
"flow_input.items[0].id",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(1),
)
.await;
assert_eval_full(
"flow_input.items.map(i => i.value)",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(["first", "second", "third"]),
)
.await;
// Combining flow_input with step results
assert_eval_full(
"flow_input.count + a",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(142),
)
.await;
assert_eval_full(
"flow_input.enabled ? b.data.users : []",
ctx.clone(),
fi.clone(),
Some(&fe),
json!([
{"id": 1, "name": "Alice", "active": true, "roles": ["admin", "user"]},
{"id": 2, "name": "Bob", "active": false, "roles": ["user"]},
{"id": 3, "name": "Charlie", "active": true, "roles": ["moderator", "user"]}
]),
)
.await;
}
#[tokio::test]
async fn test_flow_env_access() {
let (ctx, fi, fe) = create_multi_step_flow_context();
assert_eval_full(
"flow_env.ENV",
ctx.clone(),
fi.clone(),
Some(&fe),
json!("production"),
)
.await;
assert_eval_full(
"flow_env.DEBUG",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(false),
)
.await;
assert_eval_full(
"flow_env.VERSION",
ctx.clone(),
fi.clone(),
Some(&fe),
json!("1.2.3"),
)
.await;
}
#[tokio::test]
async fn test_flow_branch_conditions() {
let (ctx, fi, fe) = create_multi_step_flow_context();
assert_eval_full("a > 40", ctx.clone(), fi.clone(), Some(&fe), json!(true)).await;
assert_eval_full(
"b.status === 'success'",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(true),
)
.await;
assert_eval_full(
"flow_input.enabled",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(true),
)
.await;
assert_eval_full(
"a > 40 && b.status === 'success'",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(true),
)
.await;
assert_eval_full(
"b.data.users.length > 0",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(true),
)
.await;
assert_eval_full(
"b.data.users.some(u => u.active)",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(true),
)
.await;
assert_eval_full(
"c.includes(30)",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(true),
)
.await;
}
#[tokio::test]
async fn test_flow_complex_data_extraction() {
let (ctx, fi, fe) = create_multi_step_flow_context();
assert_eval_full(
"b.data.users.filter(u => u.active).map(u => u.name)",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(["Alice", "Charlie"]),
)
.await;
assert_eval_full(
"b.data.users.filter(u => u.roles.includes('admin'))[0]?.name",
ctx.clone(),
fi.clone(),
Some(&fe),
json!("Alice"),
)
.await;
assert_eval_full(
"b.data.users.reduce((acc, u) => acc + (u.active ? 1 : 0), 0)",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(2),
)
.await;
// Combining multiple step results
assert_eval_full("a + c[0]", ctx.clone(), fi.clone(), Some(&fe), json!(52)).await;
assert_eval_full(
"a * b.data.total",
ctx.clone(),
fi.clone(),
Some(&fe),
json!(126),
)
.await;
}
#[tokio::test]
async fn test_flow_forloop_inner_expressions() {
let mut ctx = HashMap::new();
ctx.insert(
"previous_result".to_string(),
Arc::new(to_raw_value(&json!({"value": 42, "index": 2}))),
);
let mut fi = HashMap::new();
fi.insert(
"iter".to_string(),
to_raw_value(&json!({"index": 2, "value": {"id": 3, "name": "test_item"}})),
);
fi.insert("name".to_string(), to_raw_value(&json!("parent_flow")));
let fi = Some(mappable_rc::Marc::new(fi));
assert_eval_full(
"flow_input.iter.index",
ctx.clone(),
fi.clone(),
None,
json!(2),
)
.await;
assert_eval_full(
"flow_input.iter.value.id",
ctx.clone(),
fi.clone(),
None,
json!(3),
)
.await;
assert_eval_full(
"flow_input.iter.value.name",
ctx.clone(),
fi.clone(),
None,
json!("test_item"),
)
.await;
assert_eval_full(
"`Item ${flow_input.iter.index} of ${flow_input.name}`",
ctx.clone(),
fi.clone(),
None,
json!("Item 2 of parent_flow"),
)
.await;
}
// =====================================================================
// EDGE CASES
// =====================================================================
#[tokio::test]
async fn test_edge_cases_empty_values() {
let mut env = HashMap::new();
env.insert("emptyArray".to_string(), Arc::new(to_raw_value(&json!([]))));
env.insert(
"emptyObject".to_string(),
Arc::new(to_raw_value(&json!({}))),
);
env.insert(
"emptyString".to_string(),
Arc::new(to_raw_value(&json!(""))),
);
env.insert("zero".to_string(), Arc::new(to_raw_value(&json!(0))));
assert_eval("emptyArray.length", env.clone(), json!(0)).await;
assert_eval("emptyArray.map(x => x * 2)", env.clone(), json!([])).await;
assert_eval(
"emptyArray.reduce((a, b) => a + b, 100)",
env.clone(),
json!(100),
)
.await;
assert_eval("Object.keys(emptyObject)", env.clone(), json!([])).await;
assert_eval("emptyString.length", env.clone(), json!(0)).await;
assert_eval("emptyString || 'default'", env.clone(), json!("default")).await;
assert_eval("emptyString ?? 'default'", env.clone(), json!("")).await;
assert_eval("zero || 'default'", env.clone(), json!("default")).await;
assert_eval("zero ?? 'default'", env.clone(), json!(0)).await;
}
#[tokio::test]
async fn test_edge_cases_deep_access() {
let mut env = HashMap::new();
env.insert(
"deep".to_string(),
Arc::new(to_raw_value(
&json!({"a": {"b": {"c": {"d": {"e": "found!"}}}}}),
)),
);
assert_eval("deep.a.b.c.d.e", env.clone(), json!("found!")).await;
assert_eval("deep?.a?.b?.c?.d?.e", env.clone(), json!("found!")).await;
assert_eval("deep?.a?.b?.x?.y?.z", env.clone(), json!(null)).await;
assert_eval(
"deep?.a?.b?.x?.y?.z ?? 'not found'",
env.clone(),
json!("not found"),
)
.await;
}
#[tokio::test]
async fn test_edge_cases_special_characters() {
let mut env = HashMap::new();
env.insert(
"data".to_string(),
Arc::new(to_raw_value(&json!({
"key-with-dash": "value1",
"key.with.dots": "value2",
"key with spaces": "value3"
}))),
);
assert_eval("data['key-with-dash']", env.clone(), json!("value1")).await;
assert_eval("data['key.with.dots']", env.clone(), json!("value2")).await;
assert_eval("data['key with spaces']", env.clone(), json!("value3")).await;
}
#[tokio::test]
async fn test_edge_cases_boolean_coercion() {
let env = HashMap::new();
assert_eval("Boolean(0)", env.clone(), json!(false)).await;
assert_eval("Boolean('')", env.clone(), json!(false)).await;
assert_eval("Boolean(null)", env.clone(), json!(false)).await;
assert_eval("Boolean(undefined)", env.clone(), json!(false)).await;
assert_eval("Boolean(NaN)", env.clone(), json!(false)).await;
assert_eval("Boolean(1)", env.clone(), json!(true)).await;
assert_eval("Boolean('hello')", env.clone(), json!(true)).await;
assert_eval("Boolean([])", env.clone(), json!(true)).await;
assert_eval("Boolean({})", env.clone(), json!(true)).await;
assert_eval("!!0", env.clone(), json!(false)).await;
assert_eval("!!1", env.clone(), json!(true)).await;
}
// =====================================================================
// PROMISE RESOLUTION
// =====================================================================
#[tokio::test]
async fn test_promise_resolve() {
let env = HashMap::new();
assert_eval("Promise.resolve(42)", env.clone(), json!(42)).await;
assert_eval(
"Promise.resolve({ key: 'value' })",
env.clone(),
json!({"key": "value"}),
)
.await;
assert_eval(
"Promise.all([Promise.resolve(1), Promise.resolve(2), Promise.resolve(3)])",
env.clone(),
json!([1, 2, 3]),
)
.await;
}
// =====================================================================
// eval_simple_js TESTS
// =====================================================================
#[tokio::test]
async fn test_eval_simple_js_basic() {
let mut globals = HashMap::new();
globals.insert("job".to_string(), json!({"id": "abc", "input": {"x": 42}}));
let result = eval_simple_js("job.input.x".to_string(), globals)
.await
.unwrap();
assert_eq!(result.get(), "42");
}
#[tokio::test]
async fn test_eval_simple_js_string() {
let mut globals = HashMap::new();
globals.insert("name".to_string(), json!("World"));
let result = eval_simple_js("`Hello ${name}!`".to_string(), globals)
.await
.unwrap();
assert_eq!(result.get(), "\"Hello World!\"");
}
#[tokio::test]
async fn test_eval_simple_js_array_transform() {
let mut globals = HashMap::new();
globals.insert("data".to_string(), json!([1, 2, 3, 4, 5]));
let result = eval_simple_js(
"data.filter(x => x > 2).map(x => x * 10)".to_string(),
globals,
)
.await
.unwrap();
let actual: serde_json::Value = serde_json::from_str(result.get()).unwrap();
assert_eq!(actual, json!([30, 40, 50]));
}
#[tokio::test]
async fn test_eval_simple_js_null_handling() {
let mut globals = HashMap::new();
globals.insert("x".to_string(), json!(null));
let result = eval_simple_js("x ?? 'default'".to_string(), globals)
.await
.unwrap();
assert_eq!(result.get(), "\"default\"");
}
#[tokio::test]
async fn test_eval_simple_js_multiple_globals() {
let mut globals = HashMap::new();
globals.insert("a".to_string(), json!(10));
globals.insert("b".to_string(), json!(20));
globals.insert("prefix".to_string(), json!("result"));
let result = eval_simple_js(
"({ label: `${prefix}_sum`, value: a + b })".to_string(),
globals,
)
.await
.unwrap();
let actual: serde_json::Value = serde_json::from_str(result.get()).unwrap();
assert_eq!(actual, json!({"label": "result_sum", "value": 30}));
}
#[tokio::test]
async fn test_eval_simple_js_error_handling() {
let globals = HashMap::new();
// Invalid JS should return an error
let result = eval_simple_js("this is not valid js @#$".to_string(), globals).await;
assert!(result.is_err());
}
// =====================================================================
// MEMORY LIMIT
// =====================================================================
#[test]
fn test_quickjs_memory_limit_default() {
// Absent (or invalid) env var falls back to the compiled default.
if std::env::var("QUICKJS_MEMORY_LIMIT_MB").is_err() {
assert_eq!(
*QUICKJS_MEMORY_LIMIT_BYTES,
DEFAULT_QUICKJS_MEMORY_LIMIT_BYTES
);
}
}
#[tokio::test]
async fn test_eval_oom_error_reports_memory_limit() {
// A million-element array cannot fit in a 4MB heap. Here QuickJS has room
// to build a proper InternalError; it must surface as a clear memory-limit
// message. This is the fixed-cap eval_simple_js path, so it must NOT
// advise the env var (which does not tune this path).
let err = eval_simple_js_inner(
"Array.from({ length: 1000000 }, (_, i) => i)",
&HashMap::new(),
4 * 1024 * 1024,
)
.await
.expect_err("expected OOM to fail")
.to_string();
assert!(err.contains("memory limit"), "unexpected error: {err}");
assert!(
!err.contains("QUICKJS_MEMORY_LIMIT_MB"),
"fixed-cap path must not advise the env var: {err}"
);
}
#[tokio::test]
async fn test_eval_flow_oom_reports_env_override() {
// The flow step-input transform path is env-tunable, so its OOM message
// must point at QUICKJS_MEMORY_LIMIT_MB.
let err = eval_quickjs_inner(
"Array.from({ length: 1000000 }, (_, i) => i)",
HashMap::new(),
None,
None,
None,
None,
None,
Vec::new(),
4 * 1024 * 1024,
)
.await
.expect_err("expected OOM to fail")
.to_string();
assert!(err.contains("memory limit"), "unexpected error: {err}");
assert!(
err.contains("QUICKJS_MEMORY_LIMIT_MB"),
"no env hint: {err}"
);
}
#[tokio::test]
async fn test_eval_opaque_oom_reports_memory_limit() {
// Under a cap too small to hold it, QuickJS runs out of memory while
// building the flattened array and cannot even allocate the Error object,
// so it throws a bare null that rquickjs renders as the opaque "Exception
// generated by quickjs". That must still be recognised as a memory-limit
// failure rather than surfaced opaquely (issue #8073).
let err = eval_simple_js_inner(
"Array.from({ length: 1000000 }, (_, i) => [i, i + 1, i + 2]).flat().length",
&HashMap::new(),
64 * 1024 * 1024,
)
.await
.expect_err("expected OOM to fail")
.to_string();
assert!(err.contains("memory limit"), "opaque OOM leaked: {err}");
assert!(
!err.contains("Exception generated by quickjs"),
"opaque OOM leaked: {err}"
);
}
#[tokio::test]
async fn test_eval_user_throw_not_reported_as_oom() {
// A transform that throws a non-null value must NOT be misattributed to
// the memory limit — only OOM's null/undefined throw is. The Error cases
// guard the InternalError-kind check: a plain `Error`, even one whose
// message is exactly "out of memory", is a user error, not OOM.
for expr in [
"(() => { throw 'boom' })()",
"(() => { throw new Error('boom') })()",
"(() => { throw new Error('out of memory later') })()",
"(() => { throw new Error('out of memory') })()",
] {
let err = eval_simple_js_inner(expr, &HashMap::new(), 64 * 1024 * 1024)
.await
.expect_err("expected user throw to fail")
.to_string();
assert!(
!err.contains("memory limit"),
"misreported as OOM ({expr}): {err}"
);
}
}
#[tokio::test]
async fn test_eval_bare_null_throw_treated_as_oom() {
// Documents the accepted ambiguity: QuickJS reports OOM as a bare null
// throw, indistinguishable from a user `throw null` / `throw undefined`.
// Absorbing these rare user throws into the OOM bucket is the deliberate
// tradeoff for reliably catching the far more common OOM case.
for expr in ["(() => { throw null })()", "(() => { throw undefined })()"] {
let err = eval_simple_js_inner(expr, &HashMap::new(), 64 * 1024 * 1024)
.await
.expect_err("expected throw to fail")
.to_string();
assert!(
err.contains("memory limit"),
"expected OOM bucket ({expr}): {err}"
);
}
}
#[tokio::test]
async fn test_eval_issue_payload_succeeds_with_raised_cap() {
// The exact #8073 payload exceeds the conservative default but succeeds
// once the cap is raised to 256MB (what the env override lets operators
// do), exercising the flow step-input transform path (eval_timeout_quickjs)
// via eval_quickjs_inner.
let result = eval_quickjs_inner(
"Array.from({ length: 1000000 }, (_, i) => [i, i + 1, i + 2]).flat().length",
HashMap::new(),
None,
None,
None,
None,
None,
Vec::new(),
256 * 1024 * 1024,
)
.await
.expect("issue payload should evaluate once the cap is raised");
assert_eq!(result.get(), "3000000");
}
#[tokio::test]
async fn test_eval_moderately_large_array_under_default() {
// A ~48MB array: comfortably above the 32MB range yet under the 128MB
// default, evaluated through the flow step-input transform path.
let result = eval_timeout_quickjs(
"Array.from({ length: 3000000 }, (_, i) => i).length".to_string(),
HashMap::new(),
None,
None,
None,
None,
None,
)
.await
.expect("moderately large array should evaluate under the default limit");
assert_eq!(result.get(), "3000000");
}
}