eoka_runner/runner/
mod.rs1mod 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#[derive(Debug)]
13pub struct RunResult {
14 pub success: bool,
16 pub error: Option<String>,
18 pub actions_executed: usize,
20 pub duration_ms: u64,
22 pub retries: u32,
24}
25
26pub struct Runner {
28 browser: Browser,
29 page: Page,
30}
31
32impl Runner {
33 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 pub fn page(&self) -> &Page {
56 &self.page
57 }
58
59 pub async fn run(&mut self, config: &Config) -> Result<RunResult> {
61 self.run_with_base_path(config, ".").await
62 }
63
64 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}", ×tamp.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 pub async fn close(self) -> Result<()> {
207 self.browser.close().await?;
208 Ok(())
209 }
210}