# eoka-runner
Config-based browser automation. Define actions in YAML, execute deterministically.
Part of the [eoka-tools](https://github.com/cbxss/eoka-tools) workspace.
## Quick Start
```yaml
# automation.yaml
name: "Example"
target:
url: "https://example.com"
actions:
- wait_for_network_idle:
timeout_ms: 5000
- click:
text: "More information"
- screenshot:
path: "result.png"
success:
any:
- url_contains: "/info"
```
```sh
eoka-runner automation.yaml
```
## Features
- **YAML configs** — define automation flows without writing code
- **Parameterized configs** — `${variable}` substitution for reusable flows
- **30 action types** — navigation, clicking, input, scrolling, cookies, JS, conditionals
- **Text targeting** — click by visible text, not just CSS selectors
- **Human-like actions** — optional mouse movement and typing delays
- **Retry logic** — automatic retries with configurable delay
- **Success conditions** — verify URL or text content after completion
- **Failure screenshots** — capture state on error for debugging
## CLI Usage
```sh
# Run config
eoka-runner config.yaml
# Run with parameters
eoka-runner login.yaml -P email=user@example.com -P password=secret
# Validate without running
eoka-runner config.yaml --check
# Verbose output
eoka-runner config.yaml -v # info level
eoka-runner config.yaml -vv # debug level
# Override headless mode
eoka-runner config.yaml --headless
# Quiet (errors only)
eoka-runner config.yaml -q
```
## Config Format
```yaml
name: "Automation Name"
# Parameters (optional) - for reusable configs
params:
email:
required: true
description: "User email"
timeout:
default: "5000"
description: "Wait timeout"
browser:
headless: false
proxy: "http://user:pass@host:port" # optional
user_agent: "Custom UA" # optional
viewport: # optional
width: 1920
height: 1080
target:
url: "https://example.com"
actions:
# Use ${param_name} for substitution
- fill:
selector: "#email"
value: "${email}"
success:
any: # OR conditions
- url_contains: "/success"
- text_contains: "Thank you"
# or use 'all' for AND conditions
on_failure:
screenshot: "error_{timestamp}.png"
retry:
attempts: 3
delay_ms: 2000
```
## Action Types
### Navigation
- `goto: { url }` — Navigate to URL
- `back` — Browser back
- `forward` — Browser forward
- `reload` — Refresh page
### Waiting
- `wait: { ms }` — Fixed delay
- `wait_for_network_idle: { idle_ms, timeout_ms }`
- `wait_for: { selector, timeout_ms }` — Wait for element
- `wait_for_visible: { selector, timeout_ms }`
- `wait_for_hidden: { selector, timeout_ms }`
- `wait_for_text: { text, timeout_ms }`
- `wait_for_url: { contains, timeout_ms }`
- `wait_for_email: { ... }` — Wait for IMAP email, extract link/code
### Clicking
- `click: { selector | text, human, scroll_into_view }`
- `try_click: { selector | text }` — No error if missing
- `try_click_any: { texts }` — Click first found
### Input
- `fill: { selector | text, value, human }` — Clear and type
- `type: { selector | text, value }` — Append text
- `clear: { selector | text }` — Clear input field
- `select: { selector | text, value }` — Select dropdown option
- `press_key: { key }` — Press key (Enter, Tab, Escape, ArrowDown, etc.)
### Mouse
- `hover: { selector | text }` — Hover over element
### Cookies
- `set_cookie: { name, value, domain?, path? }` — Set a cookie
- `delete_cookie: { name, domain? }` — Delete a cookie
### JavaScript
- `execute: { js }` — Run arbitrary JavaScript
### Scrolling
- `scroll: { direction, amount }`
- `scroll_to: { selector | text }`
### Debug
- `screenshot: { path }`
- `log: { message }`
- `assert_text: { text }`
- `assert_url: { contains }`
### Control Flow
- `if_text_exists: { text, then, else }`
- `if_selector_exists: { selector, then, else }`
- `repeat: { times, actions }`
### Composition
- `include: { path, params? }` — Include another config's actions
## wait_for_email
Waits for an email via IMAP, extracts a link or code, and optionally acts on it.
```yaml
actions:
- wait_for_email:
imap:
host: "imap.gmail.com"
port: 993
tls: true
username: "${imap_user}"
password: "${imap_pass}"
mailbox: "INBOX"
filter:
from: "no-reply@example.com"
subject_contains: "Confirm your email"
unseen_only: true
since_minutes: 10
mark_seen: true
timeout_ms: 120000
poll_interval_ms: 2000
extract:
link:
allow_domains: ["example.com"]
action:
open_link: {}
```
```yaml
actions:
- wait_for_email:
imap:
host: "imap.gmail.com"
username: "${imap_user}"
password: "${imap_pass}"
filter:
subject_contains: "Your verification code"
unseen_only: true
extract:
code:
regex: "(\\d{6})"
action:
fill:
selector: "input[name=code]"
```
## Reusable Flows with Include
Create reusable building blocks:
```yaml
# flows/dismiss_cookies.yaml
name: "Dismiss Cookies"
target:
url: "about:blank"
actions:
- try_click_any:
texts: ["Accept", "Accept All", "OK", "Got it"]
```
```yaml
# flows/login.yaml
name: "Login"
params:
email: { required: true }
password: { required: true }
target:
url: "about:blank"
actions:
- fill: { selector: "#email", value: "${email}" }
- fill: { selector: "#password", value: "${password}" }
- click: { text: "Sign In" }
```
Compose them in your main config:
```yaml
name: "Checkout Flow"
target:
url: "https://shop.example.com"
actions:
- include: { path: "flows/dismiss_cookies.yaml" }
- include:
path: "flows/login.yaml"
params:
email: "user@example.com"
password: "secret"
- click: { text: "Checkout" }
```
Include paths are relative to the config file's directory.
## Library Usage
```rust
use eoka_runner::{Config, Params, Runner};
// Simple usage
let config = Config::load("automation.yaml")?;
let mut runner = Runner::new(&config.browser).await?;
let result = runner.run(&config).await?;
println!("Success: {}", result.success);
runner.close().await?;
// With parameters
let params = Params::new()
.set("email", "user@example.com")
.set("password", "secret");
let config = Config::load_with_params("login.yaml", ¶ms)?;
```
## Examples
See the `configs/` directory in this crate for example YAML configs.