1use crate::models::MonitorMatch;
6use anyhow::Context;
7use async_trait::async_trait;
8use std::{any::Any, process::Stdio, time::Duration};
9use tokio::{io::AsyncWriteExt, time::timeout};
10
11#[async_trait]
14pub trait ScriptExecutor: Send + Sync + Any {
15 fn as_any(&self) -> &dyn Any;
17 async fn execute(
28 &self,
29 input: MonitorMatch,
30 timeout_ms: &u32,
31 args: Option<&[String]>,
32 from_custom_notification: bool,
33 ) -> Result<bool, anyhow::Error>;
34}
35
36pub struct PythonScriptExecutor {
38 pub script_content: String,
40}
41
42#[async_trait]
43impl ScriptExecutor for PythonScriptExecutor {
44 fn as_any(&self) -> &dyn Any {
45 self
46 }
47 async fn execute(
48 &self,
49 input: MonitorMatch,
50 timeout_ms: &u32,
51 args: Option<&[String]>,
52 from_custom_notification: bool,
53 ) -> Result<bool, anyhow::Error> {
54 let combined_input = serde_json::json!({
55 "monitor_match": input,
56 "args": args
57 });
58 let input_json = serde_json::to_string(&combined_input)
59 .with_context(|| "Failed to serialize monitor match and arguments")?;
60
61 let cmd = tokio::process::Command::new("python3")
62 .arg("-c")
63 .arg(&self.script_content)
64 .stdin(Stdio::piped())
65 .stdout(Stdio::piped())
66 .stderr(Stdio::piped())
67 .spawn()
68 .with_context(|| "Failed to spawn python3 process")?;
69
70 process_command(cmd, &input_json, timeout_ms, from_custom_notification).await
71 }
72}
73
74pub struct JavaScriptScriptExecutor {
76 pub script_content: String,
78}
79
80#[async_trait]
81impl ScriptExecutor for JavaScriptScriptExecutor {
82 fn as_any(&self) -> &dyn Any {
83 self
84 }
85 async fn execute(
86 &self,
87 input: MonitorMatch,
88 timeout_ms: &u32,
89 args: Option<&[String]>,
90 from_custom_notification: bool,
91 ) -> Result<bool, anyhow::Error> {
92 let combined_input = serde_json::json!({
94 "monitor_match": input,
95 "args": args
96 });
97 let input_json = serde_json::to_string(&combined_input)
98 .with_context(|| "Failed to serialize monitor match and arguments")?;
99
100 let cmd = tokio::process::Command::new("node")
101 .arg("-e")
102 .arg(&self.script_content)
103 .stdin(Stdio::piped())
104 .stdout(Stdio::piped())
105 .stderr(Stdio::piped())
106 .spawn()
107 .with_context(|| "Failed to spawn node process")?;
108 process_command(cmd, &input_json, timeout_ms, from_custom_notification).await
109 }
110}
111
112pub struct BashScriptExecutor {
114 pub script_content: String,
116}
117
118#[async_trait]
119impl ScriptExecutor for BashScriptExecutor {
120 fn as_any(&self) -> &dyn Any {
121 self
122 }
123 async fn execute(
124 &self,
125 input: MonitorMatch,
126 timeout_ms: &u32,
127 args: Option<&[String]>,
128 from_custom_notification: bool,
129 ) -> Result<bool, anyhow::Error> {
130 let combined_input = serde_json::json!({
132 "monitor_match": input,
133 "args": args
134 });
135
136 let input_json = serde_json::to_string(&combined_input)
137 .with_context(|| "Failed to serialize monitor match and arguments")?;
138
139 let cmd = tokio::process::Command::new("sh")
140 .arg("-c")
141 .arg(&self.script_content)
142 .stdin(Stdio::piped())
143 .stdout(Stdio::piped())
144 .stderr(Stdio::piped())
145 .spawn()
146 .with_context(|| "Failed to spawn shell process")?;
147
148 process_command(cmd, &input_json, timeout_ms, from_custom_notification).await
149 }
150}
151
152#[allow(clippy::result_large_err)]
166pub fn process_script_output(
167 output: std::process::Output,
168 from_custom_notification: bool,
169) -> Result<bool, anyhow::Error> {
170 if !output.status.success() {
171 let error_message = String::from_utf8_lossy(&output.stderr).to_string();
172 return Err(anyhow::anyhow!(
173 "Script execution failed: {}",
174 error_message
175 ));
176 }
177
178 if from_custom_notification {
181 return Ok(true);
182 }
183
184 let stdout = String::from_utf8_lossy(&output.stdout);
185
186 if stdout.trim().is_empty() {
187 return Err(anyhow::anyhow!("Script produced no output"));
188 }
189
190 let last_line = stdout
191 .lines()
192 .last()
193 .ok_or_else(|| anyhow::anyhow!("No output from script"))?
194 .trim();
195
196 match last_line.to_lowercase().as_str() {
197 "true" => Ok(true),
198 "false" => Ok(false),
199 _ => Err(anyhow::anyhow!(
200 "Last line of output is not a valid boolean: {}",
201 last_line
202 )),
203 }
204}
205
206async fn process_command(
207 mut cmd: tokio::process::Child,
208 input_json: &str,
209 timeout_ms: &u32,
210 from_custom_notification: bool,
211) -> Result<bool, anyhow::Error> {
212 if let Some(mut stdin) = cmd.stdin.take() {
213 stdin
214 .write_all(input_json.as_bytes())
215 .await
216 .map_err(|e| anyhow::anyhow!("Failed to write input to script: {}", e))?;
217
218 stdin
220 .shutdown()
221 .await
222 .map_err(|e| anyhow::anyhow!("Failed to close stdin: {}", e))?;
223 } else {
224 return Err(anyhow::anyhow!("Failed to get stdin handle"));
225 }
226
227 let timeout_duration = Duration::from_millis(u64::from(*timeout_ms));
228
229 match timeout(timeout_duration, cmd.wait_with_output()).await {
230 Ok(result) => {
231 let output =
232 result.map_err(|e| anyhow::anyhow!("Failed to wait for script output: {}", e))?;
233 process_script_output(output, from_custom_notification)
234 }
235 Err(_) => Err(anyhow::anyhow!("Script execution timed out")),
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::{
243 models::{
244 AddressWithSpec, EVMMonitorMatch, EVMReceiptLog, EventCondition, FunctionCondition,
245 MatchConditions, Monitor, MonitorMatch, TransactionCondition,
246 },
247 utils::tests::evm::{
248 monitor::MonitorBuilder, receipt::ReceiptBuilder, transaction::TransactionBuilder,
249 },
250 };
251 use std::{fs, path::Path, time::Instant};
252
253 fn read_fixture(filename: &str) -> String {
254 let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR"))
255 .join("tests/integration/fixtures/filters")
256 .join(filename);
257 fs::read_to_string(fixture_path)
258 .unwrap_or_else(|_| panic!("Failed to read fixture file: {}", filename))
259 }
260
261 fn create_test_monitor(
263 event_conditions: Vec<EventCondition>,
264 function_conditions: Vec<FunctionCondition>,
265 transaction_conditions: Vec<TransactionCondition>,
266 addresses: Vec<AddressWithSpec>,
267 ) -> Monitor {
268 let mut builder = MonitorBuilder::new()
269 .name("test")
270 .networks(vec!["evm_mainnet".to_string()]);
271
272 for event in event_conditions {
273 builder = builder.event(&event.signature, event.expression);
274 }
275 for function in function_conditions {
276 builder = builder.function(&function.signature, function.expression);
277 }
278 for transaction in transaction_conditions {
279 builder = builder.transaction(transaction.status, transaction.expression);
280 }
281
282 builder = builder.addresses_with_spec(
283 addresses
284 .into_iter()
285 .map(|a| (a.address, a.contract_spec))
286 .collect(),
287 );
288
289 builder.build()
290 }
291
292 fn create_test_evm_logs() -> Vec<EVMReceiptLog> {
293 ReceiptBuilder::new().build().logs.clone()
294 }
295
296 fn create_mock_monitor_match() -> MonitorMatch {
297 MonitorMatch::EVM(Box::new(EVMMonitorMatch {
298 monitor: create_test_monitor(vec![], vec![], vec![], vec![]),
299 transaction: TransactionBuilder::new().build(),
300 receipt: Some(ReceiptBuilder::new().build()),
301 logs: Some(create_test_evm_logs()),
302 network_slug: "evm_mainnet".to_string(),
303 matched_on: MatchConditions {
304 functions: vec![],
305 events: vec![],
306 transactions: vec![],
307 },
308 matched_on_args: None,
309 }))
310 }
311
312 #[tokio::test]
313 async fn test_python_script_executor_success() {
314 let script_content = r#"
315import sys
316import json
317
318# Read from stdin instead of command line arguments
319input_json = sys.stdin.read()
320data = json.loads(input_json)
321print("debugging...")
322def test():
323 return True
324result = test()
325print(result)
326"#;
327
328 let executor = PythonScriptExecutor {
329 script_content: script_content.to_string(),
330 };
331
332 let input = create_mock_monitor_match();
333
334 let timeout = 1000;
335 let result = executor.execute(input, &timeout, None, false).await;
336 assert!(result.is_ok());
337 assert!(result.unwrap());
338 }
339
340 #[tokio::test]
341 async fn test_python_script_executor_invalid_output() {
342 let script_content = r#"
343import sys
344input_json = sys.stdin.read()
345print("debugging...")
346def test():
347 return "not a boolean"
348result = test()
349print(result)
350"#;
351
352 let executor = PythonScriptExecutor {
353 script_content: script_content.to_string(),
354 };
355
356 let input = create_mock_monitor_match();
357 let result = executor.execute(input, &1000, None, false).await;
358 assert!(result.is_err());
359 match result {
360 Err(err) => {
361 let err_msg = err.to_string();
362 assert!(
363 err_msg.contains("Last line of output is not a valid boolean: not a boolean")
364 );
365 }
366 _ => panic!("Expected error"),
367 }
368 }
369
370 #[tokio::test]
371 async fn test_python_script_executor_multiple_prints() {
372 let script_content = r#"
373import sys
374import json
375
376# Read from stdin instead of command line arguments
377input_json = sys.stdin.read()
378data = json.loads(input_json)
379print("Starting script execution...")
380print("Processing data...")
381print("More debug info")
382print("true")
383"#;
384
385 let executor = PythonScriptExecutor {
386 script_content: script_content.to_string(),
387 };
388
389 let input = create_mock_monitor_match();
390
391 let result = executor.execute(input, &1000, None, false).await;
392 assert!(result.is_ok());
393 assert!(result.unwrap());
394 }
395
396 #[tokio::test]
397 async fn test_javascript_script_executor_success() {
398 let script_content = r#"
399 // Read input from stdin
400 (async () => {
401 let input = '';
402
403 await new Promise((resolve, reject) => {
404 process.stdin.on('data', (chunk) => {
405 input += chunk;
406 });
407
408 process.stdin.on('end', resolve);
409
410 process.stdin.on('error', reject);
411 });
412
413 try {
414 const data = JSON.parse(input);
415 console.log("debugging...");
416 console.log("finished");
417 console.log("true");
418 } catch (err) {
419 console.error(err);
420 }
421 })();
422 "#;
423
424 let executor = JavaScriptScriptExecutor {
425 script_content: script_content.to_string(),
426 };
427
428 let input = create_mock_monitor_match();
429 let result = executor.execute(input, &5000, None, false).await;
430 assert!(result.is_ok());
431 assert!(result.unwrap());
432 }
433
434 #[tokio::test]
435 async fn test_javascript_script_executor_invalid_output() {
436 let script_content = r#"
437 // Read input from stdin
438 (async () => {
439 let input = '';
440 await new Promise((resolve, reject) => {
441 process.stdin.on('data', chunk => input += chunk);
442 process.stdin.on('end', resolve);
443 process.stdin.on('error', reject);
444 });
445
446 try {
447 JSON.parse(input);
448 console.log("debugging...");
449 console.log("finished");
450 console.log("not a boolean");
451 } catch (err) {
452 console.log(err);
453 }
454 })();
455 "#;
456
457 let executor = JavaScriptScriptExecutor {
458 script_content: script_content.to_string(),
459 };
460
461 let input = create_mock_monitor_match();
462 let result = executor.execute(input, &5000, None, false).await;
463 assert!(result.is_err());
464 match result {
465 Err(err) => {
466 let err_msg = err.to_string();
467 assert!(err_msg.contains("Last line of output is not a valid boolean"));
468 }
469 _ => panic!("Expected error"),
470 }
471 }
472
473 #[tokio::test]
474 async fn test_bash_script_executor_success() {
475 let script_content = r#"
476#!/bin/bash
477set -e # Exit on any error
478input_json=$(cat)
479sleep 0.1 # Small delay to ensure process startup
480echo "debugging..."
481echo "true"
482"#;
483 let executor = BashScriptExecutor {
484 script_content: script_content.to_string(),
485 };
486
487 let input = create_mock_monitor_match();
488 let result = executor.execute(input, &1000, None, false).await;
489 assert!(result.is_ok());
490 assert!(result.unwrap());
491 }
492
493 #[tokio::test]
494 async fn test_bash_script_executor_invalid_output() {
495 let script_content = r#"
496#!/bin/bash
497set -e # Exit on any error
498input_json=$(cat)
499sleep 0.1 # Small delay to ensure process startup
500echo "debugging..."
501echo "not a boolean"
502"#;
503
504 let executor = BashScriptExecutor {
505 script_content: script_content.to_string(),
506 };
507
508 let input = create_mock_monitor_match();
509 let result = executor.execute(input, &1000, None, false).await;
510 assert!(result.is_err());
511 match result {
512 Err(e) => {
513 assert!(e
514 .to_string()
515 .contains("Last line of output is not a valid boolean"));
516 }
517 Ok(_) => {
518 panic!("Expected ParseError, got success");
519 }
520 }
521 }
522
523 #[tokio::test]
524 async fn test_script_executor_empty_output() {
525 let script_content = r#"
526import sys
527input_json = sys.stdin.read()
528# This script produces no output
529"#;
530
531 let executor = PythonScriptExecutor {
532 script_content: script_content.to_string(),
533 };
534
535 let input = create_mock_monitor_match();
536 let result = executor.execute(input, &1000, None, false).await;
537
538 match result {
539 Err(e) => {
540 assert!(e.to_string().contains("Script produced no output"));
541 }
542 _ => panic!("Expected error"),
543 }
544 }
545
546 #[tokio::test]
547 async fn test_script_executor_whitespace_output() {
548 let script_content = r#"
549import sys
550input_json = sys.stdin.read()
551print(" ")
552print(" true ") # Should handle whitespace correctly
553"#;
554
555 let executor = PythonScriptExecutor {
556 script_content: script_content.to_string(),
557 };
558
559 let input = create_mock_monitor_match();
560 let result = executor.execute(input, &1000, None, false).await;
561 assert!(result.is_ok());
562 assert!(result.unwrap());
563 }
564
565 #[tokio::test]
566 async fn test_script_executor_invalid_json_input() {
567 let script_content = r#"
568 import sys
569 import json
570
571 input_json = sys.argv[1]
572 data = json.loads(input_json)
573 print("true")
574 print("Invalid JSON input")
575 exit(1)
576 "#;
577
578 let executor = PythonScriptExecutor {
579 script_content: script_content.to_string(),
580 };
581
582 let input = create_mock_monitor_match();
584
585 let result = executor.execute(input, &1000, None, false).await;
586 assert!(result.is_err());
587 }
588
589 #[tokio::test]
590 async fn test_script_executor_with_multiple_lines_of_output() {
591 let script_content = r#"
592import sys
593import json
594
595# Read from stdin instead of command line arguments
596input_json = sys.stdin.read()
597data = json.loads(input_json)
598print("debugging...")
599print("false")
600print("true")
601print("false")
602print("true")
603"#;
604
605 let executor = PythonScriptExecutor {
606 script_content: script_content.to_string(),
607 };
608
609 let input = create_mock_monitor_match();
610
611 let result = executor.execute(input, &1000, None, false).await;
612 assert!(result.is_ok());
613 assert!(result.unwrap());
614 }
615
616 #[tokio::test]
617 async fn test_python_script_executor_monitor_match_fields() {
618 let script_content = r#"
619import sys
620import json
621
622input_json = sys.stdin.read()
623data = json.loads(input_json)
624
625monitor_match = data['monitor_match']
626# Verify it's an EVM match type
627if monitor_match['EVM']:
628 block_number = monitor_match['EVM']['transaction']['blockNumber']
629 if block_number:
630 print("true")
631 else:
632 print("false")
633else:
634 print("false")
635"#;
636
637 let executor = PythonScriptExecutor {
638 script_content: script_content.to_string(),
639 };
640
641 let input = create_mock_monitor_match();
642 let result = executor.execute(input, &1000, None, false).await;
643 assert!(!result.unwrap());
644 }
645
646 #[tokio::test]
647 async fn test_python_script_executor_with_args() {
648 let script_content = r#"
649import sys
650import json
651
652input_json = sys.stdin.read()
653data = json.loads(input_json)
654
655# Verify both fields exist
656if 'monitor_match' not in data or 'args' not in data:
657 print("false")
658 exit(1)
659
660# Test args parsing
661args = data['args']
662if "--verbose" in args:
663 print("true")
664else:
665 print("false")
666"#;
667
668 let executor = PythonScriptExecutor {
669 script_content: script_content.to_string(),
670 };
671
672 let input = create_mock_monitor_match();
673
674 let args = vec![String::from("test_argument")];
676 let result = executor
677 .execute(input.clone(), &1000, Some(&args), false)
678 .await;
679 assert!(result.is_ok());
680 assert!(!result.unwrap());
681
682 let args = vec![String::from("--verbose"), String::from("--other-arg")];
684 let result = executor
685 .execute(input.clone(), &1000, Some(&args), false)
686 .await;
687 assert!(result.is_ok());
688 assert!(result.unwrap());
689 }
690
691 #[tokio::test]
692 async fn test_python_script_executor_combined_fields() {
693 let script_content = r#"
694import sys
695import json
696
697input_json = sys.stdin.read()
698data = json.loads(input_json)
699
700monitor_match = data['monitor_match']
701args = data['args']
702
703# Test both monitor_match and args together
704expected_args = ["--verbose", "--specific_arg", "--test"]
705if (monitor_match['EVM'] and
706 args == expected_args):
707 print("true")
708else:
709 print("false")
710"#;
711
712 let executor = PythonScriptExecutor {
713 script_content: script_content.to_string(),
714 };
715
716 let input = create_mock_monitor_match();
717
718 let args = vec![
720 String::from("--verbose"),
721 String::from("--specific_arg"),
722 String::from("--test"),
723 ];
724 let result = executor
725 .execute(input.clone(), &1000, Some(&args), false)
726 .await;
727 assert!(result.is_ok());
728 assert!(result.unwrap());
729
730 let args = vec![String::from("wrong_arg")];
732 let result = executor
733 .execute(input.clone(), &1000, Some(&args), false)
734 .await;
735 assert!(result.is_ok());
736 assert!(!result.unwrap());
737 }
738
739 #[tokio::test]
740 async fn test_python_script_executor_with_verbose_arg() {
741 let script_content = read_fixture("evm_filter_by_arguments.py");
742 let executor = PythonScriptExecutor { script_content };
743 let input = create_mock_monitor_match();
744 let args = vec![String::from("--verbose")];
745 let result = executor
746 .execute(input.clone(), &1000, Some(&args), false)
747 .await;
748
749 assert!(result.is_ok());
750 assert!(result.unwrap());
751 }
752
753 #[tokio::test]
754 async fn test_python_script_executor_with_wrong_arg() {
755 let script_content = read_fixture("evm_filter_by_arguments.py");
756 let executor = PythonScriptExecutor { script_content };
757
758 let input = create_mock_monitor_match();
759 let args = vec![String::from("--wrong_arg"), String::from("--test")];
760 let result = executor
761 .execute(input.clone(), &1000, Some(&args), false)
762 .await;
763
764 assert!(result.is_ok());
765 assert!(!result.unwrap());
766 }
767
768 #[tokio::test]
769 async fn test_script_executor_with_ignore_output() {
770 let script_content = r#"
771import sys
772input_json = sys.stdin.read()
773"#;
774
775 let executor = PythonScriptExecutor {
776 script_content: script_content.to_string(),
777 };
778
779 let input = create_mock_monitor_match();
780 let result = executor.execute(input, &1000, None, true).await;
781 assert!(result.is_ok());
782 assert!(result.unwrap());
783 }
784
785 #[tokio::test]
786 async fn test_script_executor_with_non_zero_exit() {
787 let script_content = r#"
788import sys
789input_json = sys.stdin.read()
790sys.stderr.write("Error: something went wrong\n")
791sys.exit(1)
792 "#;
793
794 let executor = PythonScriptExecutor {
795 script_content: script_content.to_string(),
796 };
797
798 let input = create_mock_monitor_match();
799 let result = executor.execute(input, &1000, None, true).await;
800
801 assert!(result.is_err());
802 match result {
803 Err(e) => {
804 assert!(e
805 .to_string()
806 .contains("Script execution failed: Error: something went wrong"));
807 }
808 _ => panic!("Expected ExecutionError"),
809 }
810 }
811
812 #[tokio::test]
813 async fn test_script_notify_succeeds_within_timeout() {
814 let script_content = r#"
815import sys
816import time
817input_json = sys.stdin.read()
818time.sleep(0.3)
819 "#;
820
821 let executor = PythonScriptExecutor {
822 script_content: script_content.to_string(),
823 };
824
825 let input = create_mock_monitor_match();
826 let start_time = Instant::now();
827 let result = executor.execute(input, &1000, None, true).await;
828 let elapsed = start_time.elapsed();
829
830 assert!(result.is_ok());
831 assert!(elapsed.as_millis() >= 300);
833 assert!(elapsed.as_millis() < 1000);
835 }
836
837 #[tokio::test]
838 async fn test_script_notify_fails_within_timeout() {
839 let script_content = r#"
840import sys
841import time
842input_json = sys.stdin.read()
843time.sleep(0.5)
844 "#;
845
846 let executor = PythonScriptExecutor {
847 script_content: script_content.to_string(),
848 };
849
850 let input = create_mock_monitor_match();
851 let start_time = Instant::now();
852 let result = executor.execute(input, &400, None, true).await;
853 let elapsed = start_time.elapsed();
854
855 assert!(result.is_err());
856 assert!(elapsed.as_millis() >= 400 && elapsed.as_millis() < 600);
858 }
859
860 #[tokio::test]
861 async fn test_script_python_fails_with_non_zero_exit() {
862 let script_content = r#"
863import sys
864import time
865input_json = sys.stdin.read()
866print("This is a python test error message!", file=sys.stderr)
867sys.exit(1)
868 "#;
869
870 let executor = PythonScriptExecutor {
871 script_content: script_content.to_string(),
872 };
873
874 let input = create_mock_monitor_match();
875 let result = executor.execute(input, &1000, None, false).await;
876
877 assert!(result.is_err());
878 match result {
879 Err(e) => {
880 assert!(e
881 .to_string()
882 .contains("Script execution failed: This is a python test error message!"));
883 }
884 _ => panic!("Expected ExecutionError"),
885 }
886 }
887
888 #[tokio::test]
889 async fn test_script_javascript_fails_with_non_zero_exit() {
890 let script_content = r#"
891 // Read input from stdin
892 let input = '';
893 process.stdin.on('data', (chunk) => {
894 input += chunk;
895 });
896
897 process.stdin.on('end', () => {
898 // Parse and validate input
899 try {
900 const data = JSON.parse(input);
901 console.error("This is a JS test error message!");
902 process.exit(1);
903 } catch (err) {
904 console.error(err);
905 process.exit(1);
906 }
907 });
908 "#;
909
910 let executor = JavaScriptScriptExecutor {
911 script_content: script_content.to_string(),
912 };
913
914 let input = create_mock_monitor_match();
915 let result = executor.execute(input, &1000, None, false).await;
916
917 assert!(result.is_err());
918 match result {
919 Err(e) => {
920 assert!(e
921 .to_string()
922 .contains("Script execution failed: This is a JS test error message!"));
923 }
924 _ => panic!("Expected ExecutionError"),
925 }
926 }
927
928 #[tokio::test]
929 async fn test_script_bash_fails_with_non_zero_exit() {
930 let script_content = r#"
931#!/bin/bash
932# Read input from stdin
933input_json=$(cat)
934echo "This is a bash test error message!" >&2
935exit 1
936"#;
937
938 let executor = BashScriptExecutor {
939 script_content: script_content.to_string(),
940 };
941
942 let input = create_mock_monitor_match();
943 let result = executor.execute(input, &1000, None, false).await;
944 assert!(result.is_err());
945 match result {
946 Err(e) => {
947 assert!(e
948 .to_string()
949 .contains("Script execution failed: This is a bash test error message!"));
950 }
951 _ => panic!("Expected ExecutionError"),
952 }
953 }
954}