1mod config;
21mod runner;
22
23pub use config::{
24 Action, BrowserConfig, Config, ParamDef, Params, SuccessCondition, Target, TargetUrl,
25};
26pub use runner::{RunResult, Runner};
27
28pub type Result<T> = std::result::Result<T, Error>;
30
31#[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); assert_eq!(a.timeout_ms, 10000); } else {
415 panic!("Expected WaitForNetworkIdle");
416 }
417
418 if let Action::Click(a) = &config.actions[1] {
419 assert!(!a.human); assert!(!a.scroll_into_view); } 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, ¶ms).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 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, ¶ms).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}