Skip to main content

eoka_runner/runner/
mod.rs

1mod executor;
2
3use crate::config::{BrowserConfig, Config};
4use crate::Result;
5use eoka::{Browser, Page};
6use executor::ExecutionContext;
7use std::path::Path;
8use std::time::{Instant, SystemTime, UNIX_EPOCH};
9use tracing::{debug, info, warn};
10
11/// Result of running a config.
12#[derive(Debug)]
13pub struct RunResult {
14    /// Whether the run succeeded.
15    pub success: bool,
16    /// Error message if failed.
17    pub error: Option<String>,
18    /// Number of actions executed.
19    pub actions_executed: usize,
20    /// Total duration in milliseconds.
21    pub duration_ms: u64,
22    /// Number of retry attempts made.
23    pub retries: u32,
24}
25
26/// Executes automation configs.
27pub struct Runner {
28    browser: Browser,
29    page: Page,
30}
31
32impl Runner {
33    /// Create a new runner with browser config.
34    pub async fn new(config: &BrowserConfig) -> Result<Self> {
35        let stealth = eoka::StealthConfig {
36            headless: config.headless,
37            proxy: config.proxy.clone(),
38            user_agent: config.user_agent.clone(),
39            viewport_width: config.viewport.as_ref().map(|v| v.width).unwrap_or(1280),
40            viewport_height: config.viewport.as_ref().map(|v| v.height).unwrap_or(720),
41            ..Default::default()
42        };
43
44        debug!(
45            "Launching browser (headless: {}, proxy: {:?})",
46            config.headless, config.proxy
47        );
48        let browser = Browser::launch_with_config(stealth).await?;
49        let page = browser.new_page("about:blank").await?;
50
51        Ok(Self { browser, page })
52    }
53
54    /// Get a reference to the page (for swarm integration).
55    pub fn page(&self) -> &Page {
56        &self.page
57    }
58
59    /// Run the config with retry support.
60    pub async fn run(&mut self, config: &Config) -> Result<RunResult> {
61        self.run_with_base_path(config, ".").await
62    }
63
64    /// Run the config with a base path for resolving includes.
65    pub async fn run_with_base_path(
66        &mut self,
67        config: &Config,
68        base_path: impl AsRef<Path>,
69    ) -> Result<RunResult> {
70        let ctx = ExecutionContext::new(base_path.as_ref());
71        let start = Instant::now();
72        let retry_config = config.on_failure.as_ref().and_then(|f| f.retry.as_ref());
73        let max_attempts = retry_config.map(|r| r.attempts).unwrap_or(1);
74        let retry_delay = retry_config.map(|r| r.delay_ms).unwrap_or(0);
75
76        let mut last_error = None;
77        let mut last_actions_executed = 0;
78        let mut retries = 0;
79
80        for attempt in 1..=max_attempts {
81            if attempt > 1 {
82                retries += 1;
83                info!("Retry attempt {}/{}", attempt, max_attempts);
84                if retry_delay > 0 {
85                    tokio::time::sleep(std::time::Duration::from_millis(retry_delay)).await;
86                }
87            }
88
89            match self.run_once(config, &ctx).await {
90                Ok(result) if result.success => {
91                    return Ok(RunResult {
92                        success: true,
93                        error: None,
94                        actions_executed: result.actions_executed,
95                        duration_ms: start.elapsed().as_millis() as u64,
96                        retries,
97                    });
98                }
99                Ok(result) => {
100                    last_actions_executed = result.actions_executed;
101                    last_error = Some("success conditions not met".to_string());
102                    if attempt == max_attempts {
103                        self.handle_failure(config).await;
104                    }
105                }
106                Err(e) => {
107                    warn!("Attempt {} failed: {}", attempt, e);
108                    last_error = Some(e.to_string());
109                    if attempt == max_attempts {
110                        self.handle_failure(config).await;
111                    }
112                }
113            }
114        }
115
116        Ok(RunResult {
117            success: false,
118            error: last_error,
119            actions_executed: last_actions_executed,
120            duration_ms: start.elapsed().as_millis() as u64,
121            retries,
122        })
123    }
124
125    async fn handle_failure(&self, config: &Config) {
126        if let Some(ref on_failure) = config.on_failure {
127            if let Some(ref screenshot_path) = on_failure.screenshot {
128                let timestamp = SystemTime::now()
129                    .duration_since(UNIX_EPOCH)
130                    .map(|d| d.as_secs())
131                    .unwrap_or(0);
132                let path = screenshot_path.replace("{timestamp}", &timestamp.to_string());
133                info!("Saving failure screenshot to: {}", path);
134                if let Ok(data) = self.page.screenshot().await {
135                    if let Err(e) = std::fs::write(&path, data) {
136                        warn!("Failed to save screenshot: {}", e);
137                    }
138                }
139            }
140        }
141    }
142
143    async fn run_once(&mut self, config: &Config, ctx: &ExecutionContext) -> Result<RunResult> {
144        info!("Navigating to: {}", config.target.url);
145        self.page.goto(&config.target.url).await?;
146
147        let mut actions_executed = 0;
148        for (i, action) in config.actions.iter().enumerate() {
149            debug!("Executing action {}: {}", i + 1, action.name());
150            executor::execute_with_context(&self.page, action, ctx).await?;
151            actions_executed += 1;
152        }
153
154        let success = self.check_success(config).await?;
155        debug!("Success check: {}", success);
156
157        Ok(RunResult {
158            success,
159            error: None,
160            actions_executed,
161            duration_ms: 0,
162            retries: 0,
163        })
164    }
165
166    async fn check_success(&self, config: &Config) -> Result<bool> {
167        let Some(ref success) = config.success else {
168            return Ok(true);
169        };
170
171        if let Some(ref any) = success.any {
172            for cond in any {
173                if self.check_condition(cond).await? {
174                    return Ok(true);
175                }
176            }
177            return Ok(false);
178        }
179
180        if let Some(ref all) = success.all {
181            for cond in all {
182                if !self.check_condition(cond).await? {
183                    return Ok(false);
184                }
185            }
186        }
187
188        Ok(true)
189    }
190
191    async fn check_condition(&self, condition: &crate::config::schema::Condition) -> Result<bool> {
192        use crate::config::schema::Condition;
193        match condition {
194            Condition::UrlContains(pattern) => {
195                let url = self.page.url().await?;
196                Ok(url.contains(pattern))
197            }
198            Condition::TextContains(pattern) => {
199                let text = self.page.text().await?;
200                Ok(text.contains(pattern))
201            }
202        }
203    }
204
205    /// Close the browser.
206    pub async fn close(self) -> Result<()> {
207        self.browser.close().await?;
208        Ok(())
209    }
210}