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