mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 16:02:36 +00:00
* 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>
2985 lines
104 KiB
Rust
2985 lines
104 KiB
Rust
/*
|
||
* 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");
|
||
}
|
||
}
|