Skip to main content

eoka_runner/
lib.rs

1//! # eoka-runner
2//!
3//! Config-based browser automation. Define actions in YAML, execute deterministically.
4//!
5//! ## Quick Start
6//!
7//! ```rust,no_run
8//! use eoka_runner::{Config, Runner};
9//!
10//! # #[tokio::main]
11//! # async fn main() -> eoka_runner::Result<()> {
12//! let config = Config::load("automation.yaml")?;
13//! let mut runner = Runner::new(&config.browser).await?;
14//! let result = runner.run(&config).await?;
15//! println!("Success: {}", result.success);
16//! # Ok(())
17//! # }
18//! ```
19
20mod config;
21mod runner;
22
23pub use config::{
24    Action, BrowserConfig, Config, ParamDef, Params, SuccessCondition, Target, TargetUrl,
25};
26pub use runner::{RunResult, Runner};
27
28/// Result type for eoka-runner operations.
29pub type Result<T> = std::result::Result<T, Error>;
30
31/// Errors that can occur during config loading or execution.
32#[derive(Debug, thiserror::Error)]
33pub enum Error {
34    #[error("config error: {0}")]
35    Config(String),
36
37    #[error("yaml parse error: {0}")]
38    Yaml(#[from] serde_yaml::Error),
39
40    #[error("io error: {0}")]
41    Io(#[from] std::io::Error),
42
43    #[error("browser error: {0}")]
44    Browser(#[from] eoka::Error),
45
46    #[error("action failed: {0}")]
47    ActionFailed(String),
48
49    #[error("timeout: {0}")]
50    Timeout(String),
51
52    #[error("assertion failed: {0}")]
53    AssertionFailed(String),
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn test_parse_minimal_config() {
62        let yaml = r#"
63name: "Test"
64target:
65  url: "https://example.com"
66"#;
67        let config = Config::parse(yaml).unwrap();
68        assert_eq!(config.name, "Test");
69        assert_eq!(config.target.url, "https://example.com");
70        assert!(config.actions.is_empty());
71        assert!(!config.browser.headless);
72    }
73
74    #[test]
75    fn test_parse_browser_config() {
76        let yaml = r#"
77name: "Test"
78browser:
79  headless: true
80  proxy: "http://localhost:8080"
81  user_agent: "Custom UA"
82target:
83  url: "https://example.com"
84"#;
85        let config = Config::parse(yaml).unwrap();
86        assert!(config.browser.headless);
87        assert_eq!(config.browser.proxy, Some("http://localhost:8080".into()));
88        assert_eq!(config.browser.user_agent, Some("Custom UA".into()));
89    }
90
91    #[test]
92    fn test_parse_navigation_actions() {
93        let yaml = r#"
94name: "Test"
95target:
96  url: "https://example.com"
97actions:
98  - goto:
99      url: "https://other.com"
100  - back
101  - forward
102  - reload
103"#;
104        let config = Config::parse(yaml).unwrap();
105        assert_eq!(config.actions.len(), 4);
106
107        assert!(matches!(config.actions[0], Action::Goto(_)));
108        assert!(matches!(config.actions[1], Action::Back));
109        assert!(matches!(config.actions[2], Action::Forward));
110        assert!(matches!(config.actions[3], Action::Reload));
111    }
112
113    #[test]
114    fn test_parse_wait_actions() {
115        let yaml = r#"
116name: "Test"
117target:
118  url: "https://example.com"
119actions:
120  - wait:
121      ms: 1000
122  - wait_for_network_idle:
123      idle_ms: 500
124      timeout_ms: 5000
125  - wait_for_text:
126      text: "Hello"
127      timeout_ms: 3000
128  - wait_for_url:
129      contains: "/success"
130"#;
131        let config = Config::parse(yaml).unwrap();
132        assert_eq!(config.actions.len(), 4);
133
134        if let Action::Wait(a) = &config.actions[0] {
135            assert_eq!(a.ms, 1000);
136        } else {
137            panic!("Expected Wait action");
138        }
139
140        if let Action::WaitForNetworkIdle(a) = &config.actions[1] {
141            assert_eq!(a.idle_ms, 500);
142            assert_eq!(a.timeout_ms, 5000);
143        } else {
144            panic!("Expected WaitForNetworkIdle action");
145        }
146
147        if let Action::WaitForText(a) = &config.actions[2] {
148            assert_eq!(a.text, "Hello");
149            assert_eq!(a.timeout_ms, 3000);
150        } else {
151            panic!("Expected WaitForText action");
152        }
153    }
154
155    #[test]
156    fn test_parse_click_actions() {
157        let yaml = r##"
158name: "Test"
159target:
160  url: "https://example.com"
161actions:
162  - click:
163      selector: "#btn"
164      human: true
165      scroll_into_view: true
166  - click:
167      text: "Submit"
168  - try_click:
169      selector: ".optional"
170  - try_click_any:
171      texts: ["Accept", "OK", "Close"]
172"##;
173        let config = Config::parse(yaml).unwrap();
174        assert_eq!(config.actions.len(), 4);
175
176        if let Action::Click(a) = &config.actions[0] {
177            assert_eq!(a.target.selector, Some("#btn".into()));
178            assert!(a.human);
179            assert!(a.scroll_into_view);
180        } else {
181            panic!("Expected Click action");
182        }
183
184        if let Action::Click(a) = &config.actions[1] {
185            assert_eq!(a.target.text, Some("Submit".into()));
186            assert!(!a.human);
187        } else {
188            panic!("Expected Click action");
189        }
190
191        if let Action::TryClickAny(a) = &config.actions[3] {
192            assert_eq!(
193                a.texts,
194                Some(vec!["Accept".into(), "OK".into(), "Close".into()])
195            );
196        } else {
197            panic!("Expected TryClickAny action");
198        }
199    }
200
201    #[test]
202    fn test_parse_input_actions() {
203        let yaml = r##"
204name: "Test"
205target:
206  url: "https://example.com"
207actions:
208  - fill:
209      selector: "#email"
210      value: "test@example.com"
211      human: true
212  - type:
213      text: "Search"
214      value: "query"
215  - clear:
216      selector: "#input"
217"##;
218        let config = Config::parse(yaml).unwrap();
219        assert_eq!(config.actions.len(), 3);
220
221        if let Action::Fill(a) = &config.actions[0] {
222            assert_eq!(a.target.selector, Some("#email".into()));
223            assert_eq!(a.value, "test@example.com");
224            assert!(a.human);
225        } else {
226            panic!("Expected Fill action");
227        }
228    }
229
230    #[test]
231    fn test_parse_scroll_actions() {
232        let yaml = r##"
233name: "Test"
234target:
235  url: "https://example.com"
236actions:
237  - scroll:
238      direction: down
239      amount: 3
240  - scroll_to:
241      selector: "#footer"
242"##;
243        let config = Config::parse(yaml).unwrap();
244        assert_eq!(config.actions.len(), 2);
245
246        if let Action::Scroll(a) = &config.actions[0] {
247            assert!(matches!(
248                a.direction,
249                config::actions::ScrollDirection::Down
250            ));
251            assert_eq!(a.amount, 3);
252        } else {
253            panic!("Expected Scroll action");
254        }
255    }
256
257    #[test]
258    fn test_parse_debug_actions() {
259        let yaml = r#"
260name: "Test"
261target:
262  url: "https://example.com"
263actions:
264  - screenshot:
265      path: "test.png"
266  - log:
267      message: "Step completed"
268  - assert_text:
269      text: "Success"
270  - assert_url:
271      contains: "/done"
272"#;
273        let config = Config::parse(yaml).unwrap();
274        assert_eq!(config.actions.len(), 4);
275
276        if let Action::Screenshot(a) = &config.actions[0] {
277            assert_eq!(a.path, "test.png");
278        } else {
279            panic!("Expected Screenshot action");
280        }
281
282        if let Action::Log(a) = &config.actions[1] {
283            assert_eq!(a.message, "Step completed");
284        } else {
285            panic!("Expected Log action");
286        }
287    }
288
289    #[test]
290    fn test_parse_control_flow_actions() {
291        let yaml = r#"
292name: "Test"
293target:
294  url: "https://example.com"
295actions:
296  - if_text_exists:
297      text: "Cookie banner"
298      then:
299        - click:
300            text: "Accept"
301      else:
302        - log:
303            message: "No banner"
304  - repeat:
305      times: 3
306      actions:
307        - scroll:
308            direction: down
309"#;
310        let config = Config::parse(yaml).unwrap();
311        assert_eq!(config.actions.len(), 2);
312
313        if let Action::IfTextExists(a) = &config.actions[0] {
314            assert_eq!(a.text, "Cookie banner");
315            assert_eq!(a.then_actions.len(), 1);
316            assert_eq!(a.else_actions.len(), 1);
317        } else {
318            panic!("Expected IfTextExists action");
319        }
320
321        if let Action::Repeat(a) = &config.actions[1] {
322            assert_eq!(a.times, 3);
323            assert_eq!(a.actions.len(), 1);
324        } else {
325            panic!("Expected Repeat action");
326        }
327    }
328
329    #[test]
330    fn test_parse_success_conditions() {
331        let yaml = r#"
332name: "Test"
333target:
334  url: "https://example.com"
335success:
336  any:
337    - url_contains: "/cart"
338    - text_contains: "Added to cart"
339"#;
340        let config = Config::parse(yaml).unwrap();
341        let success = config.success.unwrap();
342        let any = success.any.unwrap();
343        assert_eq!(any.len(), 2);
344    }
345
346    #[test]
347    fn test_parse_on_failure() {
348        let yaml = r#"
349name: "Test"
350target:
351  url: "https://example.com"
352on_failure:
353  screenshot: "error.png"
354  retry:
355    attempts: 3
356    delay_ms: 1000
357"#;
358        let config = Config::parse(yaml).unwrap();
359        let on_failure = config.on_failure.unwrap();
360        assert_eq!(on_failure.screenshot, Some("error.png".into()));
361        let retry = on_failure.retry.unwrap();
362        assert_eq!(retry.attempts, 3);
363        assert_eq!(retry.delay_ms, 1000);
364    }
365
366    #[test]
367    fn test_validation_missing_name() {
368        let yaml = r#"
369target:
370  url: "https://example.com"
371"#;
372        let result = Config::parse(yaml);
373        assert!(result.is_err());
374    }
375
376    #[test]
377    fn test_validation_missing_url() {
378        let yaml = r#"
379name: "Test"
380target:
381  url: ""
382"#;
383        let result = Config::parse(yaml);
384        assert!(result.is_err());
385    }
386
387    #[test]
388    fn test_validation_empty_name() {
389        let yaml = r#"
390name: ""
391target:
392  url: "https://example.com"
393"#;
394        let result = Config::parse(yaml);
395        assert!(result.is_err());
396    }
397
398    #[test]
399    fn test_default_values() {
400        let yaml = r##"
401name: "Test"
402target:
403  url: "https://example.com"
404actions:
405  - wait_for_network_idle: {}
406  - click:
407      selector: "#btn"
408"##;
409        let config = Config::parse(yaml).unwrap();
410
411        if let Action::WaitForNetworkIdle(a) = &config.actions[0] {
412            assert_eq!(a.idle_ms, 500); // default
413            assert_eq!(a.timeout_ms, 10000); // default
414        } else {
415            panic!("Expected WaitForNetworkIdle");
416        }
417
418        if let Action::Click(a) = &config.actions[1] {
419            assert!(!a.human); // default false
420            assert!(!a.scroll_into_view); // default false
421        } else {
422            panic!("Expected Click");
423        }
424    }
425
426    #[test]
427    fn test_load_example_config() {
428        let config = Config::load("configs/example.yaml").unwrap();
429        assert_eq!(config.name, "Example Automation");
430        assert_eq!(config.target.url, "https://example.com");
431    }
432
433    #[test]
434    fn test_parse_new_actions() {
435        let yaml = r##"
436name: "Test"
437target:
438  url: "https://example.com"
439actions:
440  - select:
441      selector: "#country"
442      value: "US"
443  - press_key:
444      key: "Enter"
445  - hover:
446      text: "Menu"
447  - set_cookie:
448      name: "session"
449      value: "abc123"
450      domain: ".example.com"
451  - delete_cookie:
452      name: "tracking"
453  - execute:
454      js: "window.scrollTo(0, 0)"
455"##;
456        let config = Config::parse(yaml).unwrap();
457        assert_eq!(config.actions.len(), 6);
458
459        if let Action::Select(a) = &config.actions[0] {
460            assert_eq!(a.target.selector, Some("#country".into()));
461            assert_eq!(a.value, "US");
462        } else {
463            panic!("Expected Select action");
464        }
465
466        if let Action::PressKey(a) = &config.actions[1] {
467            assert_eq!(a.key, "Enter");
468        } else {
469            panic!("Expected PressKey action");
470        }
471
472        if let Action::Hover(a) = &config.actions[2] {
473            assert_eq!(a.target.text, Some("Menu".into()));
474        } else {
475            panic!("Expected Hover action");
476        }
477
478        if let Action::SetCookie(a) = &config.actions[3] {
479            assert_eq!(a.name, "session");
480            assert_eq!(a.value, "abc123");
481            assert_eq!(a.domain, Some(".example.com".into()));
482        } else {
483            panic!("Expected SetCookie action");
484        }
485
486        if let Action::DeleteCookie(a) = &config.actions[4] {
487            assert_eq!(a.name, "tracking");
488        } else {
489            panic!("Expected DeleteCookie action");
490        }
491
492        if let Action::Execute(a) = &config.actions[5] {
493            assert_eq!(a.js, "window.scrollTo(0, 0)");
494        } else {
495            panic!("Expected Execute action");
496        }
497    }
498
499    #[test]
500    fn test_parse_viewport_config() {
501        let yaml = r#"
502name: "Test"
503browser:
504  headless: true
505  viewport:
506    width: 1920
507    height: 1080
508  proxy: "http://localhost:8080"
509  user_agent: "Custom UA"
510target:
511  url: "https://example.com"
512"#;
513        let config = Config::parse(yaml).unwrap();
514        assert!(config.browser.headless);
515        assert_eq!(config.browser.proxy, Some("http://localhost:8080".into()));
516        assert_eq!(config.browser.user_agent, Some("Custom UA".into()));
517        let viewport = config.browser.viewport.unwrap();
518        assert_eq!(viewport.width, 1920);
519        assert_eq!(viewport.height, 1080);
520    }
521
522    #[test]
523    fn test_validation_both_any_and_all() {
524        let yaml = r#"
525name: "Test"
526target:
527  url: "https://example.com"
528success:
529  any:
530    - url_contains: "/success"
531  all:
532    - text_contains: "Done"
533"#;
534        let result = Config::parse(yaml);
535        assert!(result.is_err());
536        assert!(result
537            .unwrap_err()
538            .to_string()
539            .contains("either 'any' or 'all'"));
540    }
541
542    #[test]
543    fn test_validation_zero_retry_attempts() {
544        let yaml = r#"
545name: "Test"
546target:
547  url: "https://example.com"
548on_failure:
549  retry:
550    attempts: 0
551    delay_ms: 1000
552"#;
553        let result = Config::parse(yaml);
554        assert!(result.is_err());
555        assert!(result.unwrap_err().to_string().contains("at least 1"));
556    }
557
558    #[test]
559    fn test_params_substitution() {
560        let yaml = r##"
561name: "Login"
562params:
563  email:
564    required: true
565  password:
566    required: true
567target:
568  url: "https://example.com/login"
569actions:
570  - fill:
571      selector: "#email"
572      value: "${email}"
573  - fill:
574      selector: "#password"
575      value: "${password}"
576"##;
577        let params = Params::new()
578            .set("email", "test@example.com")
579            .set("password", "secret123");
580        let config = Config::parse_with_params(yaml, &params).unwrap();
581
582        if let Action::Fill(a) = &config.actions[0] {
583            assert_eq!(a.value, "test@example.com");
584        } else {
585            panic!("Expected Fill action");
586        }
587
588        if let Action::Fill(a) = &config.actions[1] {
589            assert_eq!(a.value, "secret123");
590        } else {
591            panic!("Expected Fill action");
592        }
593    }
594
595    #[test]
596    fn test_params_default_value() {
597        let yaml = r##"
598name: "Test"
599params:
600  search_text:
601    default: "default query"
602target:
603  url: "https://example.com"
604actions:
605  - fill:
606      selector: "#search"
607      value: "${search_text}"
608"##;
609        // No params provided - should use default
610        let config = Config::parse(yaml).unwrap();
611        if let Action::Fill(a) = &config.actions[0] {
612            assert_eq!(a.value, "default query");
613        } else {
614            panic!("Expected Fill action");
615        }
616    }
617
618    #[test]
619    fn test_params_missing_required() {
620        let yaml = r##"
621name: "Test"
622params:
623  api_key:
624    required: true
625target:
626  url: "https://example.com/${api_key}"
627"##;
628        let result = Config::parse(yaml);
629        assert!(result.is_err());
630        assert!(result.unwrap_err().to_string().contains("api_key"));
631    }
632
633    #[test]
634    fn test_params_in_target_url() {
635        let yaml = r##"
636name: "Test"
637params:
638  env:
639    default: "staging"
640target:
641  url: "https://${env}.example.com"
642"##;
643        let params = Params::new().set("env", "production");
644        let config = Config::parse_with_params(yaml, &params).unwrap();
645        assert_eq!(config.target.url, "https://production.example.com");
646    }
647
648    #[test]
649    fn test_parse_include_action() {
650        let yaml = r##"
651name: "Test"
652target:
653  url: "https://example.com"
654actions:
655  - include:
656      path: "flows/login.yaml"
657      params:
658        email: "test@example.com"
659        password: "secret"
660  - click:
661      text: "Continue"
662"##;
663        let config = Config::parse(yaml).unwrap();
664        assert_eq!(config.actions.len(), 2);
665
666        if let Action::Include(a) = &config.actions[0] {
667            assert_eq!(a.path, "flows/login.yaml");
668            assert_eq!(a.params.get("email"), Some(&"test@example.com".to_string()));
669            assert_eq!(a.params.get("password"), Some(&"secret".to_string()));
670        } else {
671            panic!("Expected Include action");
672        }
673    }
674
675    #[test]
676    fn test_parse_include_simple() {
677        let yaml = r##"
678name: "Test"
679target:
680  url: "https://example.com"
681actions:
682  - include:
683      path: "common/setup.yaml"
684"##;
685        let config = Config::parse(yaml).unwrap();
686
687        if let Action::Include(a) = &config.actions[0] {
688            assert_eq!(a.path, "common/setup.yaml");
689            assert!(a.params.is_empty());
690        } else {
691            panic!("Expected Include action");
692        }
693    }
694}