openzeppelin_relayer/services/plugins/
script_executor.rs

1//! This module is responsible for executing a typescript script.
2//!
3//! 1. Checks if `ts-node` is installed.
4//! 2. Executes the script using the `ts-node` command.
5//! 3. Returns the output and errors of the script.
6use serde::{Deserialize, Serialize};
7use std::process::Stdio;
8use tokio::process::Command;
9use utoipa::ToSchema;
10
11use super::PluginError;
12
13#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, ToSchema)]
14#[serde(rename_all = "lowercase")]
15pub enum LogLevel {
16    Log,
17    Info,
18    Error,
19    Warn,
20    Debug,
21    Result,
22}
23
24#[derive(Serialize, Deserialize, Debug, PartialEq, Clone, ToSchema)]
25pub struct LogEntry {
26    pub level: LogLevel,
27    pub message: String,
28}
29
30#[derive(Serialize, Deserialize, Debug, ToSchema)]
31pub struct ScriptResult {
32    pub logs: Vec<LogEntry>,
33    pub error: String,
34    pub trace: Vec<serde_json::Value>,
35    pub return_value: String,
36}
37
38pub struct ScriptExecutor;
39
40impl ScriptExecutor {
41    #[allow(clippy::too_many_arguments)]
42    pub async fn execute_typescript(
43        plugin_id: String,
44        script_path: String,
45        socket_path: String,
46        script_params: String,
47        http_request_id: Option<String>,
48        headers_json: Option<String>,
49        route: Option<String>,
50        config_json: Option<String>,
51        method: Option<String>,
52        query_json: Option<String>,
53    ) -> Result<ScriptResult, PluginError> {
54        if Command::new("ts-node")
55            .arg("--version")
56            .output()
57            .await
58            .is_err()
59        {
60            return Err(PluginError::SocketError(
61                "ts-node is not installed or not in PATH. Please install it with: npm install -g ts-node".to_string()
62            ));
63        }
64
65        // Use the centralized executor script instead of executing user script directly
66        // Use absolute path to avoid working directory issues in CI
67        let executor_path = std::env::current_dir()
68            .map(|cwd| cwd.join("plugins/lib/executor.ts").display().to_string())
69            .unwrap_or_else(|_| "plugins/lib/executor.ts".to_string());
70
71        let output = Command::new("ts-node")
72            .arg(executor_path)       // Execute executor script
73            .arg(socket_path)         // Socket path (argv[2])
74            .arg(plugin_id)           // Plugin ID (argv[3])
75            .arg(script_params)       // Plugin parameters (argv[4])
76            .arg(script_path)         // User script path (argv[5])
77            .arg(http_request_id.unwrap_or_default()) // HTTP x-request-id (argv[6], optional)
78            .arg(headers_json.unwrap_or_default()) // HTTP headers as JSON (argv[7], optional)
79            .arg(route.unwrap_or_default()) // Wildcard route (argv[8], optional)
80            .arg(config_json.unwrap_or_default()) // Plugin config as JSON (argv[9], optional)
81            .arg(method.unwrap_or_default()) // HTTP method (argv[10], optional)
82            .arg(query_json.unwrap_or_default()) // Query parameters as JSON (argv[11], optional)
83            .stdin(Stdio::null())
84            .stdout(Stdio::piped())
85            .stderr(Stdio::piped())
86            .output()
87            .await
88            .map_err(|e| PluginError::SocketError(format!("Failed to execute script: {e}")))?;
89
90        let stdout = String::from_utf8_lossy(&output.stdout);
91        let stderr = String::from_utf8_lossy(&output.stderr);
92
93        let (logs, return_value) =
94            Self::parse_logs(stdout.lines().map(|l| l.to_string()).collect())?;
95
96        // Check if the script failed (non-zero exit code)
97        if !output.status.success() {
98            // Try to parse error info from stderr
99            if let Some(error_line) = stderr.lines().find(|l| !l.trim().is_empty()) {
100                if let Ok(error_info) = serde_json::from_str::<serde_json::Value>(error_line) {
101                    let message = error_info["message"]
102                        .as_str()
103                        .unwrap_or(&stderr)
104                        .to_string();
105                    let status = error_info
106                        .get("status")
107                        .and_then(|v| v.as_u64())
108                        .unwrap_or(500) as u16;
109                    let code = error_info
110                        .get("code")
111                        .and_then(|v| v.as_str())
112                        .map(|s| s.to_string());
113                    let details = error_info
114                        .get("details")
115                        .cloned()
116                        .or_else(|| error_info.get("data").cloned());
117                    return Err(PluginError::HandlerError(Box::new(
118                        super::PluginHandlerPayload {
119                            message,
120                            status,
121                            code,
122                            details,
123                            logs: Some(logs),
124                            traces: None,
125                        },
126                    )));
127                }
128            }
129            // Fallback to stderr as error message
130            return Err(PluginError::HandlerError(Box::new(
131                super::PluginHandlerPayload {
132                    message: stderr.to_string(),
133                    status: 500,
134                    code: None,
135                    details: None,
136                    logs: Some(logs),
137                    traces: None,
138                },
139            )));
140        }
141
142        Ok(ScriptResult {
143            logs,
144            return_value,
145            error: stderr.to_string(),
146            trace: Vec::new(),
147        })
148    }
149
150    fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
151        let mut result = Vec::new();
152        let mut return_value = String::new();
153
154        for log in logs {
155            let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
156                PluginError::PluginExecutionError(format!("Failed to parse log: {e}"))
157            })?;
158
159            if log.level == LogLevel::Result {
160                return_value = log.message;
161            } else {
162                result.push(log);
163            }
164        }
165
166        Ok((result, return_value))
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use std::fs;
173
174    use tempfile::tempdir;
175
176    use super::*;
177
178    static TS_CONFIG: &str = r#"
179    {
180        "compilerOptions": {
181          "target": "es2016",
182          "module": "commonjs",
183          "esModuleInterop": true,
184          "forceConsistentCasingInFileNames": true,
185          "strict": true,
186          "skipLibCheck": true
187        }
188      }
189"#;
190
191    #[tokio::test]
192    async fn test_execute_typescript() {
193        let temp_dir = tempdir().unwrap();
194        let ts_config = temp_dir.path().join("tsconfig.json");
195        let script_path = temp_dir.path().join("test_execute_typescript.ts");
196        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
197
198        let content = r#"
199            export async function handler(api: any, params: any) {
200                console.log('test');
201                console.info('test-info');
202                return 'test-result';
203            }
204        "#;
205        fs::write(script_path.clone(), content).unwrap();
206        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
207
208        let result = ScriptExecutor::execute_typescript(
209            "test-plugin-1".to_string(),
210            script_path.display().to_string(),
211            socket_path.display().to_string(),
212            "{}".to_string(),
213            None,
214            None,
215            None,
216            None,
217            None,
218            None,
219        )
220        .await;
221
222        assert!(result.is_ok());
223        let result = result.unwrap();
224        assert_eq!(result.logs[0].level, LogLevel::Log);
225        assert_eq!(result.logs[0].message, "test");
226        assert_eq!(result.logs[1].level, LogLevel::Info);
227        assert_eq!(result.logs[1].message, "test-info");
228        assert_eq!(result.return_value, "test-result");
229    }
230
231    #[tokio::test]
232    async fn test_execute_typescript_with_result() {
233        let temp_dir = tempdir().unwrap();
234        let ts_config = temp_dir.path().join("tsconfig.json");
235        let script_path = temp_dir
236            .path()
237            .join("test_execute_typescript_with_result.ts");
238        let socket_path = temp_dir
239            .path()
240            .join("test_execute_typescript_with_result.sock");
241
242        let content = r#"
243            export async function handler(api: any, params: any) {
244                console.log('test');
245                console.info('test-info');
246                return {
247                    test: 'test-result',
248                    test2: 'test-result2'
249                };
250            }
251        "#;
252        fs::write(script_path.clone(), content).unwrap();
253        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
254
255        let result = ScriptExecutor::execute_typescript(
256            "test-plugin-1".to_string(),
257            script_path.display().to_string(),
258            socket_path.display().to_string(),
259            "{}".to_string(),
260            None,
261            None,
262            None,
263            None,
264            None,
265            None,
266        )
267        .await;
268
269        assert!(result.is_ok());
270        let result = result.unwrap();
271        assert_eq!(result.logs[0].level, LogLevel::Log);
272        assert_eq!(result.logs[0].message, "test");
273        assert_eq!(result.logs[1].level, LogLevel::Info);
274        assert_eq!(result.logs[1].message, "test-info");
275        assert_eq!(
276            result.return_value,
277            "{\"test\":\"test-result\",\"test2\":\"test-result2\"}"
278        );
279    }
280
281    #[tokio::test]
282    async fn test_execute_typescript_error() {
283        let temp_dir = tempdir().unwrap();
284        let ts_config = temp_dir.path().join("tsconfig.json");
285        let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
286        let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
287
288        let content = "console.logger('test');";
289        fs::write(script_path.clone(), content).unwrap();
290        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
291
292        let result = ScriptExecutor::execute_typescript(
293            "test-plugin-1".to_string(),
294            script_path.display().to_string(),
295            socket_path.display().to_string(),
296            "{}".to_string(),
297            None,
298            None,
299            None,
300            None,
301            None,
302            None,
303        )
304        .await;
305
306        // Script errors should now return an Err with PluginFailed
307        assert!(result.is_err());
308
309        if let Err(PluginError::HandlerError(ctx)) = result {
310            // The error will be from our JSON output or raw stderr
311            // It should contain error info about the logger issue
312            assert_eq!(ctx.status, 500);
313            // The message should contain something about the error
314            assert!(!ctx.message.is_empty());
315        } else {
316            panic!("Expected PluginError::HandlerError, got: {result:?}");
317        }
318    }
319
320    #[tokio::test]
321    async fn test_execute_typescript_handler_json_error() {
322        let temp_dir = tempdir().unwrap();
323        let ts_config = temp_dir.path().join("tsconfig.json");
324        let script_path = temp_dir
325            .path()
326            .join("test_execute_typescript_handler_json_error.ts");
327        let socket_path = temp_dir
328            .path()
329            .join("test_execute_typescript_handler_json_error.sock");
330
331        // This handler throws an error with code/status/details; our executor should capture
332        // and emit a normalized JSON error to stderr which the Rust side parses.
333        let content = r#"
334            export async function handler(_api: any, _params: any) {
335                const err: any = new Error('Validation failed');
336                err.code = 'VALIDATION_FAILED';
337                err.status = 422;
338                err.details = { field: 'email' };
339                throw err;
340            }
341        "#;
342        fs::write(&script_path, content).unwrap();
343        fs::write(&ts_config, TS_CONFIG.as_bytes()).unwrap();
344
345        let result = ScriptExecutor::execute_typescript(
346            "test-plugin-json-error".to_string(),
347            script_path.display().to_string(),
348            socket_path.display().to_string(),
349            "{}".to_string(),
350            None,
351            None,
352            None,
353            None,
354            None,
355            None,
356        )
357        .await;
358
359        match result {
360            Err(PluginError::HandlerError(ctx)) => {
361                assert_eq!(ctx.message, "Validation failed");
362                assert_eq!(ctx.status, 422);
363                assert_eq!(ctx.code.as_deref(), Some("VALIDATION_FAILED"));
364                let d = ctx.details.expect("details should be present");
365                assert_eq!(d["field"].as_str(), Some("email"));
366            }
367            other => panic!("Expected HandlerError, got: {other:?}"),
368        }
369    }
370    #[tokio::test]
371    async fn test_parse_logs_error() {
372        let temp_dir = tempdir().unwrap();
373        let ts_config = temp_dir.path().join("tsconfig.json");
374        let script_path = temp_dir.path().join("test_execute_typescript.ts");
375        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
376
377        let invalid_content = r#"
378            export async function handler(api: any, params: any) {
379                // Output raw invalid JSON directly to stdout (bypasses LogInterceptor)
380                process.stdout.write('invalid json line\n');
381                process.stdout.write('{"level":"log","message":"valid"}\n');
382                process.stdout.write('another invalid line\n');
383                return 'test';
384            }
385        "#;
386        fs::write(script_path.clone(), invalid_content).unwrap();
387        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
388
389        let result = ScriptExecutor::execute_typescript(
390            "test-plugin-1".to_string(),
391            script_path.display().to_string(),
392            socket_path.display().to_string(),
393            "{}".to_string(),
394            None,
395            None,
396            None,
397            None,
398            None,
399            None,
400        )
401        .await;
402
403        assert!(result.is_err());
404        assert!(result
405            .err()
406            .unwrap()
407            .to_string()
408            .contains("Failed to parse log"));
409    }
410
411    #[tokio::test]
412    async fn test_execute_typescript_with_headers() {
413        let temp_dir = tempdir().unwrap();
414        let ts_config = temp_dir.path().join("tsconfig.json");
415        let script_path = temp_dir
416            .path()
417            .join("test_execute_typescript_with_headers.ts");
418        let socket_path = temp_dir
419            .path()
420            .join("test_execute_typescript_with_headers.sock");
421
422        // Plugin using modern context pattern to access headers
423        let content = r#"
424            export async function handler(context: any) {
425                const { headers, params } = context;
426                console.log(`Received ${Object.keys(headers).length} headers`);
427                return {
428                    headerCount: Object.keys(headers).length,
429                    customHeader: headers['x-custom-header']?.[0],
430                    authHeader: headers['authorization']?.[0],
431                    multiValueHeader: headers['x-multi-value'],
432                    params: params
433                };
434            }
435        "#;
436        fs::write(script_path.clone(), content).unwrap();
437        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
438
439        // Create headers JSON matching HashMap<String, Vec<String>>
440        let headers_json = r#"{"x-custom-header":["custom-value"],"authorization":["Bearer token123"],"x-multi-value":["value1","value2"]}"#;
441
442        let result = ScriptExecutor::execute_typescript(
443            "test-plugin-headers".to_string(),
444            script_path.display().to_string(),
445            socket_path.display().to_string(),
446            r#"{"foo":"bar"}"#.to_string(),
447            None,
448            Some(headers_json.to_string()),
449            None,
450            None,
451            None,
452            None,
453        )
454        .await;
455
456        assert!(result.is_ok());
457        let result = result.unwrap();
458
459        // Verify log output
460        assert_eq!(result.logs[0].level, LogLevel::Log);
461        assert!(result.logs[0].message.contains("Received 3 headers"));
462
463        // Parse return value and verify headers were accessible
464        let return_obj: serde_json::Value =
465            serde_json::from_str(&result.return_value).expect("Failed to parse return value");
466
467        assert_eq!(return_obj["headerCount"], 3);
468        assert_eq!(return_obj["customHeader"], "custom-value");
469        assert_eq!(return_obj["authHeader"], "Bearer token123");
470        assert_eq!(
471            return_obj["multiValueHeader"],
472            serde_json::json!(["value1", "value2"])
473        );
474        assert_eq!(return_obj["params"], serde_json::json!({"foo": "bar"}));
475    }
476
477    #[tokio::test]
478    async fn test_execute_typescript_all_log_levels() {
479        let temp_dir = tempdir().unwrap();
480        let ts_config = temp_dir.path().join("tsconfig.json");
481        let script_path = temp_dir
482            .path()
483            .join("test_execute_typescript_all_log_levels.ts");
484        let socket_path = temp_dir
485            .path()
486            .join("test_execute_typescript_all_log_levels.sock");
487
488        let content = r#"
489            export async function handler(api: any, params: any) {
490                console.log('log message');
491                console.info('info message');
492                console.warn('warn message');
493                console.error('error message');
494                console.debug('debug message');
495                return 'success';
496            }
497        "#;
498        fs::write(script_path.clone(), content).unwrap();
499        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
500
501        let result = ScriptExecutor::execute_typescript(
502            "test-plugin-log-levels".to_string(),
503            script_path.display().to_string(),
504            socket_path.display().to_string(),
505            "{}".to_string(),
506            None,
507            None,
508            None,
509            None,
510            None,
511            None,
512        )
513        .await;
514
515        assert!(result.is_ok());
516        let result = result.unwrap();
517        assert_eq!(result.logs.len(), 5);
518        assert_eq!(result.logs[0].level, LogLevel::Log);
519        assert_eq!(result.logs[0].message, "log message");
520        assert_eq!(result.logs[1].level, LogLevel::Info);
521        assert_eq!(result.logs[1].message, "info message");
522        assert_eq!(result.logs[2].level, LogLevel::Warn);
523        assert_eq!(result.logs[2].message, "warn message");
524        assert_eq!(result.logs[3].level, LogLevel::Error);
525        assert_eq!(result.logs[3].message, "error message");
526        assert_eq!(result.logs[4].level, LogLevel::Debug);
527        assert_eq!(result.logs[4].message, "debug message");
528        assert_eq!(result.return_value, "success");
529    }
530
531    #[tokio::test]
532    async fn test_execute_typescript_with_request_id() {
533        let temp_dir = tempdir().unwrap();
534        let ts_config = temp_dir.path().join("tsconfig.json");
535        let script_path = temp_dir
536            .path()
537            .join("test_execute_typescript_with_request_id.ts");
538        let socket_path = temp_dir
539            .path()
540            .join("test_execute_typescript_with_request_id.sock");
541
542        // Note: The request ID is passed to the executor but not directly accessible
543        // in the plugin handler. It's used for internal tracing/logging.
544        // This test verifies the parameter is accepted without errors.
545        let content = r#"
546            export async function handler(api: any, params: any) {
547                console.log('handler executed');
548                return { status: 'ok' };
549            }
550        "#;
551        fs::write(script_path.clone(), content).unwrap();
552        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
553
554        let result = ScriptExecutor::execute_typescript(
555            "test-plugin-request-id".to_string(),
556            script_path.display().to_string(),
557            socket_path.display().to_string(),
558            "{}".to_string(),
559            Some("req-12345-abcde".to_string()),
560            None,
561            None,
562            None,
563            None,
564            None,
565        )
566        .await;
567
568        assert!(result.is_ok());
569        let result = result.unwrap();
570        assert_eq!(result.logs[0].level, LogLevel::Log);
571        assert_eq!(result.logs[0].message, "handler executed");
572        assert_eq!(result.return_value, "{\"status\":\"ok\"}");
573    }
574
575    #[tokio::test]
576    async fn test_execute_typescript_empty_return() {
577        let temp_dir = tempdir().unwrap();
578        let ts_config = temp_dir.path().join("tsconfig.json");
579        let script_path = temp_dir
580            .path()
581            .join("test_execute_typescript_empty_return.ts");
582        let socket_path = temp_dir
583            .path()
584            .join("test_execute_typescript_empty_return.sock");
585
586        let content = r#"
587            export async function handler(api: any, params: any) {
588                console.log('handler called');
589                // Return undefined (no explicit return)
590            }
591        "#;
592        fs::write(script_path.clone(), content).unwrap();
593        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
594
595        let result = ScriptExecutor::execute_typescript(
596            "test-plugin-empty-return".to_string(),
597            script_path.display().to_string(),
598            socket_path.display().to_string(),
599            "{}".to_string(),
600            None,
601            None,
602            None,
603            None,
604            None,
605            None,
606        )
607        .await;
608
609        assert!(result.is_ok());
610        let result = result.unwrap();
611        assert_eq!(result.logs[0].level, LogLevel::Log);
612        assert_eq!(result.logs[0].message, "handler called");
613        // undefined becomes empty string or "undefined" depending on serialization
614        assert!(result.return_value.is_empty() || result.return_value == "undefined");
615    }
616
617    #[tokio::test]
618    async fn test_execute_typescript_null_return() {
619        let temp_dir = tempdir().unwrap();
620        let ts_config = temp_dir.path().join("tsconfig.json");
621        let script_path = temp_dir
622            .path()
623            .join("test_execute_typescript_null_return.ts");
624        let socket_path = temp_dir
625            .path()
626            .join("test_execute_typescript_null_return.sock");
627
628        let content = r#"
629            export async function handler(api: any, params: any) {
630                console.log('returning null');
631                return null;
632            }
633        "#;
634        fs::write(script_path.clone(), content).unwrap();
635        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
636
637        let result = ScriptExecutor::execute_typescript(
638            "test-plugin-null-return".to_string(),
639            script_path.display().to_string(),
640            socket_path.display().to_string(),
641            "{}".to_string(),
642            None,
643            None,
644            None,
645            None,
646            None,
647            None,
648        )
649        .await;
650
651        assert!(result.is_ok());
652        let result = result.unwrap();
653        assert_eq!(result.return_value, "null");
654    }
655
656    #[tokio::test]
657    async fn test_execute_typescript_legacy_two_param_handler() {
658        let temp_dir = tempdir().unwrap();
659        let ts_config = temp_dir.path().join("tsconfig.json");
660        let script_path = temp_dir
661            .path()
662            .join("test_execute_typescript_legacy_handler.ts");
663        let socket_path = temp_dir
664            .path()
665            .join("test_execute_typescript_legacy_handler.sock");
666
667        // Explicitly test the legacy 2-parameter handler pattern
668        let content = r#"
669            export async function handler(api: any, params: any) {
670                console.log('legacy handler');
671                return {
672                    paramsReceived: params,
673                    handlerType: 'legacy-2-param'
674                };
675            }
676        "#;
677        fs::write(script_path.clone(), content).unwrap();
678        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
679
680        let result = ScriptExecutor::execute_typescript(
681            "test-plugin-legacy".to_string(),
682            script_path.display().to_string(),
683            socket_path.display().to_string(),
684            r#"{"key":"value"}"#.to_string(),
685            None,
686            None,
687            None,
688            None,
689            None,
690            None,
691        )
692        .await;
693
694        assert!(result.is_ok());
695        let result = result.unwrap();
696        assert_eq!(result.logs[0].level, LogLevel::Log);
697        assert_eq!(result.logs[0].message, "legacy handler");
698
699        let return_obj: serde_json::Value =
700            serde_json::from_str(&result.return_value).expect("Failed to parse return value");
701        assert_eq!(return_obj["handlerType"], "legacy-2-param");
702        assert_eq!(
703            return_obj["paramsReceived"],
704            serde_json::json!({"key": "value"})
705        );
706    }
707
708    #[tokio::test]
709    async fn test_execute_typescript_context_single_param_handler() {
710        let temp_dir = tempdir().unwrap();
711        let ts_config = temp_dir.path().join("tsconfig.json");
712        let script_path = temp_dir
713            .path()
714            .join("test_execute_typescript_context_handler.ts");
715        let socket_path = temp_dir
716            .path()
717            .join("test_execute_typescript_context_handler.sock");
718
719        // Test the modern context-based single parameter handler
720        let content = r#"
721            export async function handler(context: any) {
722                const { api, params, kv, headers } = context;
723                console.log('modern context handler');
724                return {
725                    paramsReceived: params,
726                    hasApi: !!api,
727                    hasKv: !!kv,
728                    hasHeaders: !!headers,
729                    handlerType: 'modern-context'
730                };
731            }
732        "#;
733        fs::write(script_path.clone(), content).unwrap();
734        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
735
736        let result = ScriptExecutor::execute_typescript(
737            "test-plugin-context".to_string(),
738            script_path.display().to_string(),
739            socket_path.display().to_string(),
740            r#"{"foo":"bar"}"#.to_string(),
741            None,
742            None,
743            None,
744            None,
745            None,
746            None,
747        )
748        .await;
749
750        assert!(result.is_ok());
751        let result = result.unwrap();
752
753        let return_obj: serde_json::Value =
754            serde_json::from_str(&result.return_value).expect("Failed to parse return value");
755        assert_eq!(return_obj["handlerType"], "modern-context");
756        assert_eq!(return_obj["hasApi"], true);
757        assert_eq!(return_obj["hasKv"], true);
758        assert_eq!(return_obj["hasHeaders"], true);
759        assert_eq!(
760            return_obj["paramsReceived"],
761            serde_json::json!({"foo": "bar"})
762        );
763    }
764
765    #[tokio::test]
766    async fn test_execute_typescript_complex_params() {
767        let temp_dir = tempdir().unwrap();
768        let ts_config = temp_dir.path().join("tsconfig.json");
769        let script_path = temp_dir
770            .path()
771            .join("test_execute_typescript_complex_params.ts");
772        let socket_path = temp_dir
773            .path()
774            .join("test_execute_typescript_complex_params.sock");
775
776        let content = r#"
777            export async function handler(api: any, params: any) {
778                console.log(`Received ${params.items.length} items`);
779                return {
780                    processedItems: params.items.map((item: any) => ({
781                        ...item,
782                        processed: true
783                    })),
784                    metadata: params.metadata,
785                    totalCount: params.items.length
786                };
787            }
788        "#;
789        fs::write(script_path.clone(), content).unwrap();
790        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
791
792        let complex_params = r#"{
793            "items": [
794                {"id": 1, "name": "item1", "tags": ["a", "b"]},
795                {"id": 2, "name": "item2", "tags": ["c", "d"]}
796            ],
797            "metadata": {
798                "source": "test",
799                "timestamp": 1234567890,
800                "nested": {
801                    "deep": {
802                        "value": true
803                    }
804                }
805            }
806        }"#;
807
808        let result = ScriptExecutor::execute_typescript(
809            "test-plugin-complex-params".to_string(),
810            script_path.display().to_string(),
811            socket_path.display().to_string(),
812            complex_params.to_string(),
813            None,
814            None,
815            None,
816            None,
817            None,
818            None,
819        )
820        .await;
821
822        assert!(result.is_ok());
823        let result = result.unwrap();
824        assert_eq!(result.logs[0].level, LogLevel::Log);
825        assert!(result.logs[0].message.contains("Received 2 items"));
826
827        let return_obj: serde_json::Value =
828            serde_json::from_str(&result.return_value).expect("Failed to parse return value");
829        assert_eq!(return_obj["totalCount"], 2);
830        assert_eq!(return_obj["processedItems"][0]["processed"], true);
831        assert_eq!(return_obj["processedItems"][1]["processed"], true);
832        assert_eq!(return_obj["processedItems"][0]["name"], "item1");
833        assert_eq!(return_obj["metadata"]["source"], "test");
834        assert_eq!(return_obj["metadata"]["nested"]["deep"]["value"], true);
835    }
836
837    #[tokio::test]
838    async fn test_execute_typescript_empty_params() {
839        let temp_dir = tempdir().unwrap();
840        let ts_config = temp_dir.path().join("tsconfig.json");
841        let script_path = temp_dir
842            .path()
843            .join("test_execute_typescript_empty_params.ts");
844        let socket_path = temp_dir
845            .path()
846            .join("test_execute_typescript_empty_params.sock");
847
848        let content = r#"
849            export async function handler(api: any, params: any) {
850                console.log(`Params is empty: ${Object.keys(params).length === 0}`);
851                return { receivedEmptyParams: Object.keys(params).length === 0 };
852            }
853        "#;
854        fs::write(script_path.clone(), content).unwrap();
855        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
856
857        let result = ScriptExecutor::execute_typescript(
858            "test-plugin-empty-params".to_string(),
859            script_path.display().to_string(),
860            socket_path.display().to_string(),
861            "{}".to_string(),
862            None,
863            None,
864            None,
865            None,
866            None,
867            None,
868        )
869        .await;
870
871        assert!(result.is_ok());
872        let result = result.unwrap();
873        assert!(result.logs[0].message.contains("Params is empty: true"));
874
875        let return_obj: serde_json::Value =
876            serde_json::from_str(&result.return_value).expect("Failed to parse return value");
877        assert_eq!(return_obj["receivedEmptyParams"], true);
878    }
879
880    #[tokio::test]
881    async fn test_execute_typescript_array_return() {
882        let temp_dir = tempdir().unwrap();
883        let ts_config = temp_dir.path().join("tsconfig.json");
884        let script_path = temp_dir
885            .path()
886            .join("test_execute_typescript_array_return.ts");
887        let socket_path = temp_dir
888            .path()
889            .join("test_execute_typescript_array_return.sock");
890
891        let content = r#"
892            export async function handler(api: any, params: any) {
893                console.log('returning array');
894                return [1, 2, 3, { nested: 'value' }, 'string'];
895            }
896        "#;
897        fs::write(script_path.clone(), content).unwrap();
898        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
899
900        let result = ScriptExecutor::execute_typescript(
901            "test-plugin-array-return".to_string(),
902            script_path.display().to_string(),
903            socket_path.display().to_string(),
904            "{}".to_string(),
905            None,
906            None,
907            None,
908            None,
909            None,
910            None,
911        )
912        .await;
913
914        assert!(result.is_ok());
915        let result = result.unwrap();
916
917        let return_array: serde_json::Value =
918            serde_json::from_str(&result.return_value).expect("Failed to parse return value");
919        assert!(return_array.is_array());
920        assert_eq!(return_array[0], 1);
921        assert_eq!(return_array[1], 2);
922        assert_eq!(return_array[2], 3);
923        assert_eq!(return_array[3]["nested"], "value");
924        assert_eq!(return_array[4], "string");
925    }
926
927    #[tokio::test]
928    async fn test_execute_typescript_multiple_errors() {
929        let temp_dir = tempdir().unwrap();
930        let ts_config = temp_dir.path().join("tsconfig.json");
931        let script_path = temp_dir
932            .path()
933            .join("test_execute_typescript_multiple_errors.ts");
934        let socket_path = temp_dir
935            .path()
936            .join("test_execute_typescript_multiple_errors.sock");
937
938        let content = r#"
939            export async function handler(api: any, params: any) {
940                console.error('Error message 1');
941                console.error('Error message 2');
942                console.warn('Warning message');
943                throw new Error('Handler failed');
944            }
945        "#;
946        fs::write(script_path.clone(), content).unwrap();
947        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
948
949        let result = ScriptExecutor::execute_typescript(
950            "test-plugin-multiple-errors".to_string(),
951            script_path.display().to_string(),
952            socket_path.display().to_string(),
953            "{}".to_string(),
954            None,
955            None,
956            None,
957            None,
958            None,
959            None,
960        )
961        .await;
962
963        assert!(result.is_err());
964        if let Err(PluginError::HandlerError(ctx)) = result {
965            // Should capture logs even when handler fails
966            let logs = ctx.logs.expect("logs should be present");
967            assert_eq!(logs.len(), 3);
968            assert_eq!(logs[0].level, LogLevel::Error);
969            assert_eq!(logs[0].message, "Error message 1");
970            assert_eq!(logs[1].level, LogLevel::Error);
971            assert_eq!(logs[1].message, "Error message 2");
972            assert_eq!(logs[2].level, LogLevel::Warn);
973            assert_eq!(logs[2].message, "Warning message");
974            assert!(ctx.message.contains("Handler failed"));
975        } else {
976            panic!("Expected HandlerError");
977        }
978    }
979
980    #[tokio::test]
981    async fn test_execute_typescript_with_route() {
982        let temp_dir = tempdir().unwrap();
983        let ts_config = temp_dir.path().join("tsconfig.json");
984        let script_path = temp_dir
985            .path()
986            .join("test_execute_typescript_with_route.ts");
987        let socket_path = temp_dir
988            .path()
989            .join("test_execute_typescript_with_route.sock");
990
991        // Plugin using modern context pattern to access route
992        let content = r#"
993            export async function handler(context: any) {
994                const { route, params } = context;
995                console.log(`Received route: ${route}`);
996                return {
997                    route: route,
998                    params: params
999                };
1000            }
1001        "#;
1002        fs::write(script_path.clone(), content).unwrap();
1003        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
1004
1005        let result = ScriptExecutor::execute_typescript(
1006            "test-plugin-route".to_string(),
1007            script_path.display().to_string(),
1008            socket_path.display().to_string(),
1009            r#"{"foo":"bar"}"#.to_string(),
1010            None,
1011            None,
1012            Some("/verify".to_string()),
1013            None,
1014            None,
1015            None,
1016        )
1017        .await;
1018
1019        assert!(result.is_ok());
1020        let result = result.unwrap();
1021
1022        // Verify log output
1023        assert_eq!(result.logs[0].level, LogLevel::Log);
1024        assert!(result.logs[0].message.contains("Received route: /verify"));
1025
1026        // Parse return value and verify route was accessible
1027        let return_obj: serde_json::Value =
1028            serde_json::from_str(&result.return_value).expect("Failed to parse return value");
1029
1030        assert_eq!(return_obj["route"], "/verify");
1031        assert_eq!(return_obj["params"], serde_json::json!({"foo": "bar"}));
1032    }
1033}