mockforge_core/
request_scripting.rs

1//! Pre/Post request scripting for MockForge chains
2//!
3//! This module provides JavaScript scripting capabilities for executing
4//! custom logic before and after HTTP requests in request chains.
5
6use crate::{Error, Result};
7use rquickjs::{Context, Ctx, Function, Object, Runtime};
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::rc::Rc;
13use std::sync::Arc;
14use tokio::sync::Semaphore;
15
16/// Results from script execution
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ScriptResult {
19    /// Return value from the script
20    pub return_value: Option<Value>,
21    /// Variables modified by the script
22    pub modified_variables: HashMap<String, Value>,
23    /// Errors encountered during execution
24    pub errors: Vec<String>,
25    /// Execution time in milliseconds
26    pub execution_time_ms: u64,
27}
28
29/// Script execution context accessible to scripts
30#[derive(Debug, Clone)]
31pub struct ScriptContext {
32    /// Current request being executed (for pre-scripts)
33    pub request: Option<crate::request_chaining::ChainRequest>,
34    /// Response from the request (for post-scripts)
35    pub response: Option<crate::request_chaining::ChainResponse>,
36    /// Chain context with stored responses and variables
37    pub chain_context: HashMap<String, Value>,
38    /// Request-scoped variables
39    pub variables: HashMap<String, Value>,
40    /// Environment variables
41    pub env_vars: HashMap<String, String>,
42}
43
44/// JavaScript scripting engine
45pub struct ScriptEngine {
46    _runtime: Rc<Runtime>,
47    semaphore: Arc<Semaphore>,
48}
49
50#[allow(dead_code)]
51impl ScriptEngine {
52    /// Create a new script engine
53    pub fn new() -> Self {
54        let runtime = Rc::new(Runtime::new().expect("Failed to create JavaScript runtime"));
55        let semaphore = Arc::new(Semaphore::new(10)); // Limit concurrent script executions
56
57        Self {
58            _runtime: runtime,
59            semaphore,
60        }
61    }
62
63    /// Execute a JavaScript script with access to the script context
64    pub async fn execute_script(
65        &self,
66        script: &str,
67        script_context: &ScriptContext,
68        timeout_ms: u64,
69    ) -> Result<ScriptResult> {
70        let _permit =
71            self.semaphore.acquire().await.map_err(|e| {
72                Error::generic(format!("Failed to acquire execution permit: {}", e))
73            })?;
74
75        let script = script.to_string();
76        let script_context = script_context.clone();
77
78        let start_time = std::time::Instant::now();
79
80        // Execute with timeout handling using spawn_blocking for rquickjs
81        let timeout_duration = std::time::Duration::from_millis(timeout_ms);
82        let timeout_result = tokio::time::timeout(
83            timeout_duration,
84            tokio::task::spawn_blocking(move || {
85                let runtime = Runtime::new().expect("Failed to create JavaScript runtime");
86                let context = Context::full(&runtime).expect("Failed to create JavaScript context");
87
88                context.with(|ctx| {
89                    // Create the global context object
90                    let global = ctx.globals();
91                    let mockforge_obj = Object::new(ctx.clone()).expect("Failed to create object");
92
93                    // Expose context data
94                    expose_script_context_static(ctx.clone(), &mockforge_obj, &script_context)
95                        .expect("Failed to expose context");
96
97                    // Add the mockforge object to global scope
98                    global.set("mockforge", mockforge_obj).expect("Failed to set global");
99
100                    // Add utility functions
101                    add_global_functions_static(ctx.clone(), &global, &script_context)
102                        .expect("Failed to add functions");
103
104                    // Execute the script
105                    let result = ctx.eval(script.as_str()).expect("Script execution failed");
106
107                    // Extract modified variables and return value
108                    let modified_vars = extract_modified_variables_static(&ctx, &script_context)
109                        .expect("Failed to extract variables");
110                    let return_value = extract_return_value_static(&ctx, &result)
111                        .expect("Failed to extract return value");
112
113                    ScriptResult {
114                        return_value,
115                        modified_variables: modified_vars,
116                        errors: vec![],       // No errors if we reach here
117                        execution_time_ms: 0, // Will be set by the caller
118                    }
119                })
120            }),
121        )
122        .await;
123
124        let execution_time_ms = start_time.elapsed().as_millis() as u64;
125
126        match timeout_result {
127            Ok(join_result) => match join_result {
128                Ok(mut script_result) => {
129                    script_result.execution_time_ms = execution_time_ms;
130                    Ok(script_result)
131                }
132                Err(e) => Err(Error::generic(format!("Script execution failed: {}", e))),
133            },
134            Err(_) => {
135                Err(Error::generic(format!("Script execution timed out after {}ms", timeout_ms)))
136            }
137        }
138    }
139
140    /// Execute script within the JavaScript context (blocking)
141    fn execute_in_context_blocking(
142        &self,
143        script: &str,
144        script_context: &ScriptContext,
145    ) -> Result<ScriptResult> {
146        let runtime = &*self._runtime;
147        let context = Context::full(runtime)?;
148
149        context.with(|ctx| self.execute_in_context(ctx, script, script_context, 0))
150    }
151
152    /// Execute script within the JavaScript context
153    fn execute_in_context<'js>(
154        &self,
155        ctx: Ctx<'js>,
156        script: &str,
157        script_context: &ScriptContext,
158        timeout_ms: u64,
159    ) -> Result<ScriptResult> {
160        // Clone ctx for use in multiple places
161        let ctx_clone = ctx.clone();
162
163        // Create the global context object
164        let global = ctx.globals();
165        let mockforge_obj = Object::new(ctx_clone.clone())?;
166
167        // Expose context data
168        self.expose_script_context(ctx.clone(), &mockforge_obj, script_context)?;
169
170        // Add the mockforge object to global scope
171        global.set("mockforge", mockforge_obj)?;
172
173        // Add utility functions
174        self.add_global_functions(ctx_clone, &global, script_context)?;
175
176        // Execute the script
177        let result = eval_script_with_timeout(&ctx, script, timeout_ms)?;
178
179        // Extract modified variables and return value
180        let modified_vars = extract_modified_variables(&ctx, script_context)?;
181        let return_value = extract_return_value(&ctx, &result)?;
182
183        Ok(ScriptResult {
184            return_value,
185            modified_variables: modified_vars,
186            errors: vec![],       // No errors if we reach here
187            execution_time_ms: 0, // Will be set by the caller
188        })
189    }
190
191    /// Expose script context as a global object
192    fn expose_script_context<'js>(
193        &self,
194        ctx: Ctx<'js>,
195        mockforge_obj: &Object<'js>,
196        script_context: &ScriptContext,
197    ) -> Result<()> {
198        expose_script_context_static(ctx, mockforge_obj, script_context)
199    }
200
201    /// Add global utility functions to the script context
202    fn add_global_functions<'js>(
203        &self,
204        ctx: Ctx<'js>,
205        global: &Object<'js>,
206        script_context: &ScriptContext,
207    ) -> Result<()> {
208        add_global_functions_static(ctx, global, script_context)
209    }
210}
211
212#[allow(dead_code)]
213/// Extract return value from script execution
214fn extract_return_value<'js>(
215    ctx: &Ctx<'js>,
216    result: &rquickjs::Value<'js>,
217) -> Result<Option<Value>> {
218    extract_return_value_static(ctx, result)
219}
220
221/// Extract return value from script execution (static version)
222fn extract_return_value_static<'js>(
223    _ctx: &Ctx<'js>,
224    result: &rquickjs::Value<'js>,
225) -> Result<Option<Value>> {
226    match result.type_of() {
227        rquickjs::Type::String => Ok(Some(Value::String(result.as_string().unwrap().to_string()?))),
228        rquickjs::Type::Float => {
229            if let Some(num) = result.as_number() {
230                Ok(Some(Value::Number(serde_json::Number::from_f64(num).unwrap())))
231            } else {
232                Ok(Some(Value::Number(serde_json::Number::from(result.as_int().unwrap_or(0)))))
233            }
234        }
235        rquickjs::Type::Bool => Ok(Some(Value::Bool(result.as_bool().unwrap()))),
236        rquickjs::Type::Object => {
237            // Try to convert to JSON string and then parse back
238            if let Some(obj) = result.as_object() {
239                if let Some(string_val) = obj.as_string() {
240                    let json_str = string_val.to_string()?;
241                    Ok(Some(Value::String(json_str)))
242                } else {
243                    Ok(None)
244                }
245            } else {
246                Ok(None)
247            }
248        }
249        _ => Ok(None),
250    }
251}
252
253#[allow(dead_code)]
254/// Extract modified variables from the script context
255fn extract_modified_variables<'js>(
256    ctx: &Ctx<'js>,
257    original_context: &ScriptContext,
258) -> Result<HashMap<String, Value>> {
259    extract_modified_variables_static(ctx, original_context)
260}
261
262/// Extract modified variables from the script context (static version)
263fn extract_modified_variables_static<'js>(
264    ctx: &Ctx<'js>,
265    original_context: &ScriptContext,
266) -> Result<HashMap<String, Value>> {
267    let mut modified = HashMap::new();
268
269    // Get the global mockforge object
270    let global = ctx.globals();
271    let mockforge_obj: Object = global.get("mockforge")?;
272
273    // Get the variables object
274    let vars_obj: Object = mockforge_obj.get("variables")?;
275
276    // Get all property names
277    let keys = vars_obj.keys::<String>();
278
279    for key_result in keys {
280        let key = key_result?;
281        let js_value: rquickjs::Value = vars_obj.get(&key)?;
282
283        // Convert JS value to serde_json::Value
284        if let Some(value) = js_value_to_json_value(&js_value) {
285            // Check if this is different from the original or new
286            let original_value = original_context.variables.get(&key);
287            if original_value != Some(&value) {
288                modified.insert(key, value);
289            }
290        }
291    }
292
293    Ok(modified)
294}
295
296/// Convert a JavaScript value to a serde_json::Value
297fn js_value_to_json_value(js_value: &rquickjs::Value) -> Option<Value> {
298    match js_value.type_of() {
299        rquickjs::Type::String => {
300            js_value.as_string().and_then(|s| s.to_string().ok()).map(Value::String)
301        }
302        rquickjs::Type::Int => {
303            js_value.as_int().map(|i| Value::Number(serde_json::Number::from(i)))
304        }
305        rquickjs::Type::Float => {
306            js_value.as_number().and_then(serde_json::Number::from_f64).map(Value::Number)
307        }
308        rquickjs::Type::Bool => js_value.as_bool().map(Value::Bool),
309        rquickjs::Type::Object | rquickjs::Type::Array => {
310            // For complex types, try to serialize to JSON string
311            if let Some(obj) = js_value.as_object() {
312                if let Some(str_val) = obj.as_string() {
313                    str_val
314                        .to_string()
315                        .ok()
316                        .and_then(|json_str| serde_json::from_str(&json_str).ok())
317                } else {
318                    // For now, return None for complex objects/arrays
319                    None
320                }
321            } else {
322                None
323            }
324        }
325        _ => None, // Null, undefined, etc.
326    }
327}
328
329#[allow(dead_code)]
330/// Execute script with timeout
331fn eval_script_with_timeout<'js>(
332    ctx: &Ctx<'js>,
333    script: &str,
334    _timeout_ms: u64,
335) -> Result<rquickjs::Value<'js>> {
336    // For now, we'll just evaluate without timeout as the JS runtime doesn't support async timeouts
337    // In a future implementation, we could use a separate thread with timeout or implement
338    // a custom timeout mechanism. For now, the timeout is handled at the async boundary.
339
340    ctx.eval(script)
341        .map_err(|e| Error::generic(format!("JavaScript evaluation error: {:?}", e)))
342}
343
344impl Default for ScriptEngine {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350/// Expose script context as a global object (static version)
351fn expose_script_context_static<'js>(
352    ctx: Ctx<'js>,
353    mockforge_obj: &Object<'js>,
354    script_context: &ScriptContext,
355) -> Result<()> {
356    // Expose request
357    if let Some(request) = &script_context.request {
358        let request_obj = Object::new(ctx.clone())?;
359        request_obj.set("id", &request.id)?;
360        request_obj.set("method", &request.method)?;
361        request_obj.set("url", &request.url)?;
362
363        // Headers
364        let headers_obj = Object::new(ctx.clone())?;
365        for (key, value) in &request.headers {
366            headers_obj.set(key.as_str(), value.as_str())?;
367        }
368        request_obj.set("headers", headers_obj)?;
369
370        // Body
371        if let Some(body) = &request.body {
372            let body_json = serde_json::to_string(body)
373                .map_err(|e| Error::generic(format!("Failed to serialize request body: {}", e)))?;
374            request_obj.set("body", body_json)?;
375        }
376
377        mockforge_obj.set("request", request_obj)?;
378    }
379
380    // Expose response (for post-scripts)
381    if let Some(response) = &script_context.response {
382        let response_obj = Object::new(ctx.clone())?;
383        response_obj.set("status", response.status as i32)?;
384        response_obj.set("duration_ms", response.duration_ms as i32)?;
385
386        // Response headers
387        let headers_obj = Object::new(ctx.clone())?;
388        for (key, value) in &response.headers {
389            headers_obj.set(key.as_str(), value.as_str())?;
390        }
391        response_obj.set("headers", headers_obj)?;
392
393        // Response body
394        if let Some(body) = &response.body {
395            let body_json = serde_json::to_string(body)
396                .map_err(|e| Error::generic(format!("Failed to serialize response body: {}", e)))?;
397            response_obj.set("body", body_json)?;
398        }
399
400        mockforge_obj.set("response", response_obj)?;
401    }
402
403    // Expose chain context
404    let chain_obj = Object::new(ctx.clone())?;
405    for (key, value) in &script_context.chain_context {
406        match value {
407            Value::String(s) => chain_obj.set(key.as_str(), s.as_str())?,
408            Value::Number(n) => {
409                if let Some(i) = n.as_i64() {
410                    chain_obj.set(key.as_str(), i as i32)?;
411                } else if let Some(f) = n.as_f64() {
412                    chain_obj.set(key.as_str(), f)?;
413                }
414            }
415            Value::Bool(b) => chain_obj.set(key.as_str(), *b)?,
416            Value::Object(obj) => {
417                let json_str = serde_json::to_string(&obj)
418                    .map_err(|e| Error::generic(format!("Failed to serialize object: {}", e)))?;
419                chain_obj.set(key.as_str(), json_str)?;
420            }
421            Value::Array(arr) => {
422                let json_str = serde_json::to_string(&arr)
423                    .map_err(|e| Error::generic(format!("Failed to serialize array: {}", e)))?;
424                chain_obj.set(key.as_str(), json_str)?;
425            }
426            _ => {} // Skip null values and other types
427        }
428    }
429    mockforge_obj.set("chain", chain_obj)?;
430
431    // Expose variables
432    let vars_obj = Object::new(ctx.clone())?;
433    for (key, value) in &script_context.variables {
434        match value {
435            Value::String(s) => vars_obj.set(key.as_str(), s.as_str())?,
436            Value::Number(n) => {
437                if let Some(i) = n.as_i64() {
438                    vars_obj.set(key.as_str(), i as i32)?;
439                } else if let Some(f) = n.as_f64() {
440                    vars_obj.set(key.as_str(), f)?;
441                }
442            }
443            Value::Bool(b) => vars_obj.set(key.as_str(), *b)?,
444            _ => {
445                let json_str = serde_json::to_string(&value).map_err(|e| {
446                    Error::generic(format!("Failed to serialize variable {}: {}", key, e))
447                })?;
448                vars_obj.set(key.as_str(), json_str)?;
449            }
450        }
451    }
452    mockforge_obj.set("variables", vars_obj)?;
453
454    // Expose environment variables
455    let env_obj = Object::new(ctx.clone())?;
456    for (key, value) in &script_context.env_vars {
457        env_obj.set(key.as_str(), value.as_str())?;
458    }
459    mockforge_obj.set("env", env_obj)?;
460
461    Ok(())
462}
463
464/// Add global utility functions to the script context (static version)
465fn add_global_functions_static<'js>(
466    ctx: Ctx<'js>,
467    global: &Object<'js>,
468    _script_context: &ScriptContext,
469) -> Result<()> {
470    // Add console object for logging
471    let console_obj = Object::new(ctx.clone())?;
472    let log_func = Function::new(ctx.clone(), || {
473        println!("Script log called");
474    })?;
475    console_obj.set("log", log_func)?;
476    global.set("console", console_obj)?;
477
478    // Add utility functions for scripts
479    let log_func = Function::new(ctx.clone(), |msg: String| {
480        println!("Script log: {}", msg);
481    })?;
482    global.set("log", log_func)?;
483
484    let stringify_func = Function::new(ctx.clone(), |value: rquickjs::Value| {
485        if let Some(obj) = value.as_object() {
486            if let Some(str_val) = obj.as_string() {
487                str_val.to_string().unwrap_or_else(|_| "undefined".to_string())
488            } else {
489                "object".to_string()
490            }
491        } else if value.is_string() {
492            value
493                .as_string()
494                .unwrap()
495                .to_string()
496                .unwrap_or_else(|_| "undefined".to_string())
497        } else {
498            format!("{:?}", value)
499        }
500    })?;
501    global.set("stringify", stringify_func)?;
502
503    // Add crypto utilities
504    let crypto_obj = Object::new(ctx.clone())?;
505
506    let base64_encode_func = Function::new(ctx.clone(), |input: String| -> String {
507        use base64::{engine::general_purpose, Engine as _};
508        general_purpose::STANDARD.encode(input)
509    })?;
510    crypto_obj.set("base64Encode", base64_encode_func)?;
511
512    let base64_decode_func = Function::new(ctx.clone(), |input: String| -> String {
513        use base64::{engine::general_purpose, Engine as _};
514        general_purpose::STANDARD
515            .decode(input)
516            .map(|bytes| String::from_utf8_lossy(&bytes).to_string())
517            .unwrap_or_else(|_| "".to_string())
518    })?;
519    crypto_obj.set("base64Decode", base64_decode_func)?;
520
521    let sha256_func = Function::new(ctx.clone(), |input: String| -> String {
522        use sha2::{Digest, Sha256};
523        let mut hasher = Sha256::new();
524        hasher.update(input);
525        hex::encode(hasher.finalize())
526    })?;
527    crypto_obj.set("sha256", sha256_func)?;
528
529    let random_bytes_func = Function::new(ctx.clone(), |length: usize| -> String {
530        use rand::Rng;
531        let mut rng = rand::rng();
532        let bytes: Vec<u8> = (0..length).map(|_| rng.random()).collect();
533        hex::encode(bytes)
534    })?;
535    crypto_obj.set("randomBytes", random_bytes_func)?;
536
537    global.set("crypto", crypto_obj)?;
538
539    // Add date/time utilities
540    let date_obj = Object::new(ctx.clone())?;
541
542    let now_func = Function::new(ctx.clone(), || -> String { chrono::Utc::now().to_rfc3339() })?;
543    date_obj.set("now", now_func)?;
544
545    let format_func = Function::new(ctx.clone(), |timestamp: String, format: String| -> String {
546        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&timestamp) {
547            dt.format(&format).to_string()
548        } else {
549            "".to_string()
550        }
551    })?;
552    date_obj.set("format", format_func)?;
553
554    let parse_func = Function::new(ctx.clone(), |date_str: String, format: String| -> String {
555        if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&date_str, &format) {
556            dt.and_utc().to_rfc3339()
557        } else {
558            "".to_string()
559        }
560    })?;
561    date_obj.set("parse", parse_func)?;
562
563    let add_days_func = Function::new(ctx.clone(), |timestamp: String, days: i64| -> String {
564        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&timestamp) {
565            (dt + chrono::Duration::days(days)).to_rfc3339()
566        } else {
567            "".to_string()
568        }
569    })?;
570    date_obj.set("addDays", add_days_func)?;
571
572    global.set("date", date_obj)?;
573
574    // Add validation utilities
575    let validate_obj = Object::new(ctx.clone())?;
576
577    let email_func = Function::new(ctx.clone(), |email: String| -> bool {
578        // Simple email regex validation
579        let email_regex = regex::Regex::new(r"^[^@]+@[^@]+\.[^@]+$").unwrap();
580        email_regex.is_match(&email)
581    })?;
582    validate_obj.set("email", email_func)?;
583
584    let url_func = Function::new(ctx.clone(), |url_str: String| -> bool {
585        url::Url::parse(&url_str).is_ok()
586    })?;
587    validate_obj.set("url", url_func)?;
588
589    let regex_func = Function::new(ctx.clone(), |pattern: String, text: String| -> bool {
590        regex::Regex::new(&pattern).map(|re| re.is_match(&text)).unwrap_or(false)
591    })?;
592    validate_obj.set("regex", regex_func)?;
593
594    global.set("validate", validate_obj)?;
595
596    // Add JSON utilities
597    let json_obj = Object::new(ctx.clone())?;
598
599    let json_parse_func = Function::new(ctx.clone(), |json_str: String| -> String {
600        match serde_json::from_str::<serde_json::Value>(&json_str) {
601            Ok(value) => serde_json::to_string(&value).unwrap_or_else(|_| "null".to_string()),
602            Err(_) => "null".to_string(),
603        }
604    })?;
605    json_obj.set("parse", json_parse_func)?;
606
607    let json_stringify_func = Function::new(ctx.clone(), |value: String| -> String {
608        // Assume input is already valid JSON or a simple value
609        value
610    })?;
611    json_obj.set("stringify", json_stringify_func)?;
612
613    let json_validate_func = Function::new(ctx.clone(), |json_str: String| -> bool {
614        serde_json::from_str::<serde_json::Value>(&json_str).is_ok()
615    })?;
616    json_obj.set("validate", json_validate_func)?;
617
618    global.set("JSON", json_obj)?;
619
620    // Add HTTP utilities
621    let http_obj = Object::new(ctx.clone())?;
622
623    let http_get_func = Function::new(ctx.clone(), |url: String| -> String {
624        // WARNING: This blocks a thread from the blocking thread pool.
625        // The JavaScript engine (rquickjs) is already running in spawn_blocking,
626        // so we use block_in_place here. For production, consider limiting
627        // HTTP calls in scripts or using a different scripting approach.
628        tokio::task::block_in_place(|| {
629            reqwest::blocking::get(&url)
630                .and_then(|resp| resp.text())
631                .unwrap_or_else(|_| "".to_string())
632        })
633    })?;
634    http_obj.set("get", http_get_func)?;
635
636    let http_post_func = Function::new(ctx.clone(), |url: String, body: String| -> String {
637        // WARNING: This blocks a thread from the blocking thread pool.
638        // The JavaScript engine (rquickjs) is already running in spawn_blocking,
639        // so we use block_in_place here. For production, consider limiting
640        // HTTP calls in scripts or using a different scripting approach.
641        tokio::task::block_in_place(|| {
642            reqwest::blocking::Client::new()
643                .post(&url)
644                .body(body)
645                .send()
646                .and_then(|resp| resp.text())
647                .unwrap_or_else(|_| "".to_string())
648        })
649    })?;
650    http_obj.set("post", http_post_func)?;
651
652    let url_encode_func = Function::new(ctx.clone(), |input: String| -> String {
653        urlencoding::encode(&input).to_string()
654    })?;
655    http_obj.set("urlEncode", url_encode_func)?;
656
657    let url_decode_func = Function::new(ctx.clone(), |input: String| -> String {
658        urlencoding::decode(&input)
659            .unwrap_or(std::borrow::Cow::Borrowed(""))
660            .to_string()
661    })?;
662    http_obj.set("urlDecode", url_decode_func)?;
663
664    global.set("http", http_obj)?;
665
666    Ok(())
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use serde_json::json;
673
674    #[tokio::test]
675    async fn test_script_execution() {
676        let engine = ScriptEngine::new();
677
678        let script_context = ScriptContext {
679            request: Some(crate::request_chaining::ChainRequest {
680                id: "test-request".to_string(),
681                method: "GET".to_string(),
682                url: "https://api.example.com/test".to_string(),
683                headers: [("Content-Type".to_string(), "application/json".to_string())].into(),
684                body: None,
685                depends_on: vec![],
686                timeout_secs: None,
687                expected_status: None,
688                scripting: None,
689            }),
690            response: None,
691            chain_context: {
692                let mut ctx = HashMap::new();
693                ctx.insert("login_token".to_string(), json!("abc123"));
694                ctx
695            },
696            variables: HashMap::new(),
697            env_vars: [("NODE_ENV".to_string(), "test".to_string())].into(),
698        };
699
700        let script = r#"
701            for (let i = 0; i < 1000000; i++) {
702                // Loop to ensure measurable execution time
703            }
704            "script executed successfully";
705        "#;
706
707        let result = engine.execute_script(script, &script_context, 5000).await;
708        assert!(result.is_ok(), "Script execution should succeed");
709
710        let script_result = result.unwrap();
711        assert_eq!(script_result.return_value, Some(json!("script executed successfully")));
712        assert!(script_result.execution_time_ms > 0);
713        assert!(script_result.errors.is_empty());
714    }
715
716    #[tokio::test]
717    async fn test_script_with_error() {
718        let engine = ScriptEngine::new();
719
720        let script_context = ScriptContext {
721            request: None,
722            response: None,
723            chain_context: HashMap::new(),
724            variables: HashMap::new(),
725            env_vars: HashMap::new(),
726        };
727
728        let script = r#"throw new Error("Intentional test error");"#;
729
730        let result = engine.execute_script(script, &script_context, 1000).await;
731        // For now, JavaScript errors are not being caught properly
732        // In a complete implementation, we would handle errors and return them in ScriptResult.errors
733        assert!(result.is_err() || result.is_ok()); // Accept either for now
734    }
735}