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, 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, 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    pub async fn execute_typescript(
42        script_path: String,
43        socket_path: String,
44        script_params: String,
45    ) -> Result<ScriptResult, PluginError> {
46        if Command::new("ts-node")
47            .arg("--version")
48            .output()
49            .await
50            .is_err()
51        {
52            return Err(PluginError::SocketError(
53                "ts-node is not installed or not in PATH. Please install it with: npm install -g ts-node".to_string()
54            ));
55        }
56
57        let output = Command::new("ts-node")
58            .arg(script_path)
59            .arg(socket_path)
60            .arg(script_params)
61            .stdin(Stdio::null())
62            .stdout(Stdio::piped())
63            .stderr(Stdio::piped())
64            .output()
65            .await
66            .map_err(|e| PluginError::SocketError(format!("Failed to execute script: {}", e)))?;
67
68        let stdout = String::from_utf8_lossy(&output.stdout);
69        let stderr = String::from_utf8_lossy(&output.stderr);
70
71        let (logs, return_value) =
72            Self::parse_logs(stdout.lines().map(|l| l.to_string()).collect())?;
73
74        Ok(ScriptResult {
75            logs,
76            return_value,
77            error: stderr.to_string(),
78            trace: Vec::new(),
79        })
80    }
81
82    fn parse_logs(logs: Vec<String>) -> Result<(Vec<LogEntry>, String), PluginError> {
83        let mut result = Vec::new();
84        let mut return_value = String::new();
85
86        for log in logs {
87            let log: LogEntry = serde_json::from_str(&log).map_err(|e| {
88                PluginError::PluginExecutionError(format!("Failed to parse log: {}", e))
89            })?;
90
91            if log.level == LogLevel::Result {
92                return_value = log.message;
93            } else {
94                result.push(log);
95            }
96        }
97
98        Ok((result, return_value))
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use std::fs;
105
106    use tempfile::tempdir;
107
108    use super::*;
109
110    static TS_CONFIG: &str = r#"
111    {
112        "compilerOptions": {
113          "target": "es2016",
114          "module": "commonjs",
115          "esModuleInterop": true,
116          "forceConsistentCasingInFileNames": true,
117          "strict": true,
118          "skipLibCheck": true
119        }
120      }
121"#;
122
123    #[tokio::test]
124    async fn test_execute_typescript() {
125        let temp_dir = tempdir().unwrap();
126        let ts_config = temp_dir.path().join("tsconfig.json");
127        let script_path = temp_dir.path().join("test_execute_typescript.ts");
128        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
129
130        let content = r#"
131            console.log(JSON.stringify({ level: 'log', message: 'test' }));
132            console.log(JSON.stringify({ level: 'info', message: 'test-info' }));
133            console.log(JSON.stringify({ level: 'result', message: 'test-result' }));
134        "#;
135        fs::write(script_path.clone(), content).unwrap();
136        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
137
138        let result = ScriptExecutor::execute_typescript(
139            script_path.display().to_string(),
140            socket_path.display().to_string(),
141            "{}".to_string(),
142        )
143        .await;
144
145        assert!(result.is_ok());
146        let result = result.unwrap();
147        assert_eq!(result.logs[0].level, LogLevel::Log);
148        assert_eq!(result.logs[0].message, "test");
149        assert_eq!(result.logs[1].level, LogLevel::Info);
150        assert_eq!(result.logs[1].message, "test-info");
151        assert_eq!(result.return_value, "test-result");
152    }
153
154    #[tokio::test]
155    async fn test_execute_typescript_error() {
156        let temp_dir = tempdir().unwrap();
157        let ts_config = temp_dir.path().join("tsconfig.json");
158        let script_path = temp_dir.path().join("test_execute_typescript_error.ts");
159        let socket_path = temp_dir.path().join("test_execute_typescript_error.sock");
160
161        let content = "console.logger('test');";
162        fs::write(script_path.clone(), content).unwrap();
163        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
164
165        let result = ScriptExecutor::execute_typescript(
166            script_path.display().to_string(),
167            socket_path.display().to_string(),
168            "{}".to_string(),
169        )
170        .await;
171
172        assert!(result.is_ok());
173
174        let result = result.unwrap();
175        assert!(result.error.contains("logger"));
176    }
177
178    #[tokio::test]
179    async fn test_parse_logs_error() {
180        let temp_dir = tempdir().unwrap();
181        let ts_config = temp_dir.path().join("tsconfig.json");
182        let script_path = temp_dir.path().join("test_execute_typescript.ts");
183        let socket_path = temp_dir.path().join("test_execute_typescript.sock");
184
185        let invalid_content = r#"
186            console.log({ level: 'log', message: 'test' });
187            console.log({ level: 'info', message: 'test-info' });
188            console.log({ level: 'result', message: 'test-result' });
189        "#;
190        fs::write(script_path.clone(), invalid_content).unwrap();
191        fs::write(ts_config.clone(), TS_CONFIG.as_bytes()).unwrap();
192
193        let result = ScriptExecutor::execute_typescript(
194            script_path.display().to_string(),
195            socket_path.display().to_string(),
196            "{}".to_string(),
197        )
198        .await;
199
200        assert!(result.is_err());
201        assert!(result
202            .err()
203            .unwrap()
204            .to_string()
205            .contains("Failed to parse log"));
206    }
207}