Skip to main content

eoka_runner/config/
actions.rs

1use serde::de::{self, MapAccess, Visitor};
2use serde::{Deserialize, Deserializer};
3use std::fmt;
4
5/// A target element - either by CSS selector or visible text.
6#[derive(Debug, Clone, Deserialize, Default)]
7pub struct Target {
8    /// CSS selector.
9    pub selector: Option<String>,
10    /// Visible text to find.
11    pub text: Option<String>,
12}
13
14impl fmt::Display for Target {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match (&self.selector, &self.text) {
17            (Some(s), _) => write!(f, "selector '{}'", s),
18            (_, Some(t)) => write!(f, "text '{}'", t),
19            _ => write!(f, "unknown"),
20        }
21    }
22}
23
24/// An action to execute in the browser.
25#[derive(Debug, Clone)]
26pub enum Action {
27    // Navigation
28    Goto(GotoAction),
29    Back,
30    Forward,
31    Reload,
32
33    // Waiting
34    Wait(WaitAction),
35    WaitForNetworkIdle(WaitForNetworkIdleAction),
36    WaitFor(WaitForAction),
37    WaitForVisible(WaitForAction),
38    WaitForHidden(WaitForAction),
39    WaitForText(WaitForTextAction),
40    WaitForUrl(WaitForUrlAction),
41    WaitForEmail(WaitForEmailAction),
42
43    // Clicking
44    Click(ClickAction),
45    TryClick(TargetAction),
46    TryClickAny(TryClickAnyAction),
47
48    // Input
49    Fill(FillAction),
50    Type(TypeAction),
51    Clear(ClearAction),
52    Select(SelectAction),
53    PressKey(PressKeyAction),
54
55    // Mouse
56    Hover(TargetAction),
57
58    // Cookies
59    SetCookie(SetCookieAction),
60    DeleteCookie(DeleteCookieAction),
61
62    // JavaScript
63    Execute(ExecuteAction),
64
65    // Scrolling
66    Scroll(ScrollAction),
67    ScrollTo(TargetAction),
68
69    // Debug
70    Screenshot(ScreenshotAction),
71    Log(LogAction),
72    AssertText(AssertTextAction),
73    AssertUrl(AssertUrlAction),
74
75    // Control flow
76    IfTextExists(IfTextExistsAction),
77    IfSelectorExists(IfSelectorExistsAction),
78    Repeat(RepeatAction),
79
80    // Composition
81    Include(IncludeAction),
82}
83
84impl Action {
85    /// Short name for logging.
86    pub fn name(&self) -> &'static str {
87        match self {
88            Self::Goto(_) => "goto",
89            Self::Back => "back",
90            Self::Forward => "forward",
91            Self::Reload => "reload",
92            Self::Wait(_) => "wait",
93            Self::WaitForNetworkIdle(_) => "wait_for_network_idle",
94            Self::WaitFor(_) => "wait_for",
95            Self::WaitForVisible(_) => "wait_for_visible",
96            Self::WaitForHidden(_) => "wait_for_hidden",
97            Self::WaitForText(_) => "wait_for_text",
98            Self::WaitForUrl(_) => "wait_for_url",
99            Self::WaitForEmail(_) => "wait_for_email",
100            Self::Click(_) => "click",
101            Self::TryClick(_) => "try_click",
102            Self::TryClickAny(_) => "try_click_any",
103            Self::Fill(_) => "fill",
104            Self::Type(_) => "type",
105            Self::Clear(_) => "clear",
106            Self::Select(_) => "select",
107            Self::PressKey(_) => "press_key",
108            Self::Hover(_) => "hover",
109            Self::SetCookie(_) => "set_cookie",
110            Self::DeleteCookie(_) => "delete_cookie",
111            Self::Execute(_) => "execute",
112            Self::Scroll(_) => "scroll",
113            Self::ScrollTo(_) => "scroll_to",
114            Self::Screenshot(_) => "screenshot",
115            Self::Log(_) => "log",
116            Self::AssertText(_) => "assert_text",
117            Self::AssertUrl(_) => "assert_url",
118            Self::IfTextExists(_) => "if_text_exists",
119            Self::IfSelectorExists(_) => "if_selector_exists",
120            Self::Repeat(_) => "repeat",
121            Self::Include(_) => "include",
122        }
123    }
124}
125
126const ACTION_NAMES: &[&str] = &[
127    "goto",
128    "back",
129    "forward",
130    "reload",
131    "wait",
132    "wait_for_network_idle",
133    "wait_for",
134    "wait_for_visible",
135    "wait_for_hidden",
136    "wait_for_text",
137    "wait_for_url",
138    "wait_for_email",
139    "click",
140    "try_click",
141    "try_click_any",
142    "fill",
143    "type",
144    "clear",
145    "select",
146    "press_key",
147    "hover",
148    "set_cookie",
149    "delete_cookie",
150    "execute",
151    "scroll",
152    "scroll_to",
153    "screenshot",
154    "log",
155    "assert_text",
156    "assert_url",
157    "if_text_exists",
158    "if_selector_exists",
159    "repeat",
160    "include",
161];
162
163impl<'de> Deserialize<'de> for Action {
164    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
165    where
166        D: Deserializer<'de>,
167    {
168        deserializer.deserialize_any(ActionVisitor)
169    }
170}
171
172struct ActionVisitor;
173
174impl<'de> Visitor<'de> for ActionVisitor {
175    type Value = Action;
176
177    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
178        formatter.write_str("an action (string for unit variants, or map with single key)")
179    }
180
181    fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
182    where
183        E: de::Error,
184    {
185        match value {
186            "back" => Ok(Action::Back),
187            "forward" => Ok(Action::Forward),
188            "reload" => Ok(Action::Reload),
189            other => Err(de::Error::unknown_variant(
190                other,
191                &["back", "forward", "reload"],
192            )),
193        }
194    }
195
196    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
197    where
198        M: MapAccess<'de>,
199    {
200        let key: String = map
201            .next_key()?
202            .ok_or_else(|| de::Error::custom("expected action type key"))?;
203
204        let action = match key.as_str() {
205            "goto" => Action::Goto(map.next_value()?),
206            "back" => {
207                let _: serde_yaml::Value = map.next_value()?;
208                Action::Back
209            }
210            "forward" => {
211                let _: serde_yaml::Value = map.next_value()?;
212                Action::Forward
213            }
214            "reload" => {
215                let _: serde_yaml::Value = map.next_value()?;
216                Action::Reload
217            }
218            "wait" => Action::Wait(map.next_value()?),
219            "wait_for_network_idle" => Action::WaitForNetworkIdle(map.next_value()?),
220            "wait_for" => Action::WaitFor(map.next_value()?),
221            "wait_for_visible" => Action::WaitForVisible(map.next_value()?),
222            "wait_for_hidden" => Action::WaitForHidden(map.next_value()?),
223            "wait_for_text" => Action::WaitForText(map.next_value()?),
224            "wait_for_url" => Action::WaitForUrl(map.next_value()?),
225            "wait_for_email" => Action::WaitForEmail(map.next_value()?),
226            "click" => Action::Click(map.next_value()?),
227            "try_click" => Action::TryClick(map.next_value()?),
228            "try_click_any" => Action::TryClickAny(map.next_value()?),
229            "fill" => Action::Fill(map.next_value()?),
230            "type" => Action::Type(map.next_value()?),
231            "clear" => Action::Clear(map.next_value()?),
232            "select" => Action::Select(map.next_value()?),
233            "press_key" => Action::PressKey(map.next_value()?),
234            "hover" => Action::Hover(map.next_value()?),
235            "set_cookie" => Action::SetCookie(map.next_value()?),
236            "delete_cookie" => Action::DeleteCookie(map.next_value()?),
237            "execute" => Action::Execute(map.next_value()?),
238            "scroll" => Action::Scroll(map.next_value()?),
239            "scroll_to" => Action::ScrollTo(map.next_value()?),
240            "screenshot" => Action::Screenshot(map.next_value()?),
241            "log" => Action::Log(map.next_value()?),
242            "assert_text" => Action::AssertText(map.next_value()?),
243            "assert_url" => Action::AssertUrl(map.next_value()?),
244            "if_text_exists" => Action::IfTextExists(map.next_value()?),
245            "if_selector_exists" => Action::IfSelectorExists(map.next_value()?),
246            "repeat" => Action::Repeat(map.next_value()?),
247            "include" => Action::Include(map.next_value()?),
248            other => return Err(de::Error::unknown_variant(other, ACTION_NAMES)),
249        };
250
251        Ok(action)
252    }
253}
254
255// --- Action payloads ---
256
257#[derive(Debug, Clone, Deserialize)]
258pub struct GotoAction {
259    pub url: String,
260}
261
262#[derive(Debug, Clone, Deserialize)]
263pub struct WaitAction {
264    pub ms: u64,
265}
266
267fn default_idle_ms() -> u64 {
268    500
269}
270fn default_timeout_ms() -> u64 {
271    10000
272}
273
274#[derive(Debug, Clone, Deserialize)]
275pub struct WaitForNetworkIdleAction {
276    #[serde(default = "default_idle_ms")]
277    pub idle_ms: u64,
278    #[serde(default = "default_timeout_ms")]
279    pub timeout_ms: u64,
280}
281
282#[derive(Debug, Clone, Deserialize)]
283pub struct WaitForAction {
284    pub selector: String,
285    #[serde(default = "default_timeout_ms")]
286    pub timeout_ms: u64,
287}
288
289#[derive(Debug, Clone, Deserialize)]
290pub struct WaitForTextAction {
291    pub text: String,
292    #[serde(default = "default_timeout_ms")]
293    pub timeout_ms: u64,
294}
295
296#[derive(Debug, Clone, Deserialize)]
297pub struct WaitForUrlAction {
298    pub contains: String,
299    #[serde(default = "default_timeout_ms")]
300    pub timeout_ms: u64,
301}
302
303#[derive(Debug, Clone, Deserialize)]
304pub struct ImapConfigAction {
305    pub host: String,
306    #[serde(default = "ImapConfigAction::default_port")]
307    pub port: u16,
308    #[serde(default = "ImapConfigAction::default_tls")]
309    pub tls: bool,
310    pub username: String,
311    pub password: String,
312    #[serde(default = "ImapConfigAction::default_mailbox")]
313    pub mailbox: String,
314}
315
316impl ImapConfigAction {
317    fn default_port() -> u16 { 993 }
318    fn default_tls() -> bool { true }
319    fn default_mailbox() -> String { "INBOX".into() }
320}
321
322#[derive(Debug, Clone, Deserialize)]
323pub struct EmailFilterAction {
324    pub from: Option<String>,
325    pub subject_contains: Option<String>,
326    #[serde(default = "EmailFilterAction::default_unseen_only")]
327    pub unseen_only: bool,
328    pub since_minutes: Option<i64>,
329    #[serde(default)]
330    pub mark_seen: bool,
331}
332
333impl EmailFilterAction {
334    fn default_unseen_only() -> bool { true }
335}
336
337impl Default for EmailFilterAction {
338    fn default() -> Self {
339        Self {
340            from: None,
341            subject_contains: None,
342            unseen_only: true,
343            since_minutes: None,
344            mark_seen: false,
345        }
346    }
347}
348
349#[derive(Debug, Clone, Deserialize)]
350pub struct WaitForEmailAction {
351    pub imap: ImapConfigAction,
352    #[serde(default)]
353    pub filter: EmailFilterAction,
354    #[serde(default = "WaitForEmailAction::default_timeout_ms")]
355    pub timeout_ms: u64,
356    #[serde(default = "WaitForEmailAction::default_poll_interval_ms")]
357    pub poll_interval_ms: u64,
358    #[serde(default)]
359    pub extract: EmailExtractAction,
360    #[serde(default)]
361    pub action: Option<EmailAction>,
362}
363
364impl WaitForEmailAction {
365    fn default_timeout_ms() -> u64 { 120_000 }
366    fn default_poll_interval_ms() -> u64 { 2_000 }
367}
368
369#[derive(Debug, Clone, Deserialize, Default)]
370pub struct EmailExtractAction {
371    pub link: Option<EmailLinkExtract>,
372    pub code: Option<EmailCodeExtract>,
373}
374
375#[derive(Debug, Clone, Deserialize)]
376pub struct EmailLinkExtract {
377    pub allow_domains: Option<Vec<String>>,
378}
379
380#[derive(Debug, Clone, Deserialize)]
381pub struct EmailCodeExtract {
382    pub regex: String,
383}
384
385#[derive(Debug, Clone, Deserialize)]
386#[serde(rename_all = "snake_case")]
387pub enum EmailAction {
388    OpenLink(EmailOpenLinkAction),
389    Fill(EmailFillAction),
390}
391
392/// Empty config — accepts both `open_link: {}` and bare `open_link:` in YAML.
393#[derive(Debug, Clone, Default)]
394pub struct EmailOpenLinkAction;
395
396impl<'de> Deserialize<'de> for EmailOpenLinkAction {
397    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
398    where
399        D: Deserializer<'de>,
400    {
401        // Accept null/unit (bare `open_link:`) or empty map (`open_link: {}`)
402        let v = serde_yaml::Value::deserialize(deserializer)?;
403        match v {
404            serde_yaml::Value::Null | serde_yaml::Value::Mapping(_) => Ok(Self),
405            _ => Err(serde::de::Error::custom("expected null or empty map for open_link")),
406        }
407    }
408}
409
410#[derive(Debug, Clone, Deserialize)]
411pub struct EmailFillAction {
412    pub selector: String,
413}
414
415#[derive(Debug, Clone, Deserialize)]
416pub struct ClickAction {
417    #[serde(flatten)]
418    pub target: Target,
419    #[serde(default)]
420    pub human: bool,
421    #[serde(default)]
422    pub scroll_into_view: bool,
423}
424
425#[derive(Debug, Clone, Deserialize)]
426pub struct TryClickAnyAction {
427    pub selectors: Option<Vec<String>>,
428    pub texts: Option<Vec<String>>,
429}
430
431#[derive(Debug, Clone, Deserialize)]
432pub struct FillAction {
433    #[serde(flatten)]
434    pub target: Target,
435    pub value: String,
436    #[serde(default)]
437    pub human: bool,
438}
439
440#[derive(Debug, Clone, Deserialize)]
441pub struct TypeAction {
442    #[serde(flatten)]
443    pub target: Target,
444    pub value: String,
445}
446
447#[derive(Debug, Clone, Deserialize)]
448pub struct ClearAction {
449    #[serde(flatten)]
450    pub target: Target,
451}
452
453#[derive(Debug, Clone, Deserialize)]
454pub struct SelectAction {
455    #[serde(flatten)]
456    pub target: Target,
457    pub value: String,
458}
459
460#[derive(Debug, Clone, Deserialize)]
461pub struct PressKeyAction {
462    pub key: String,
463}
464
465/// Generic action that just needs a target element.
466#[derive(Debug, Clone, Deserialize)]
467pub struct TargetAction {
468    #[serde(flatten)]
469    pub target: Target,
470}
471
472#[derive(Debug, Clone, Deserialize)]
473pub struct SetCookieAction {
474    pub name: String,
475    pub value: String,
476    pub domain: Option<String>,
477    pub path: Option<String>,
478}
479
480#[derive(Debug, Clone, Deserialize)]
481pub struct DeleteCookieAction {
482    pub name: String,
483    pub domain: Option<String>,
484}
485
486#[derive(Debug, Clone, Deserialize)]
487pub struct ExecuteAction {
488    pub js: String,
489}
490
491fn default_scroll_amount() -> u32 {
492    1
493}
494
495#[derive(Debug, Clone, Deserialize)]
496pub struct ScrollAction {
497    pub direction: ScrollDirection,
498    #[serde(default = "default_scroll_amount")]
499    pub amount: u32,
500}
501
502#[derive(Debug, Clone, Deserialize)]
503#[serde(rename_all = "snake_case")]
504pub enum ScrollDirection {
505    Up,
506    Down,
507    Left,
508    Right,
509}
510
511#[derive(Debug, Clone, Deserialize)]
512pub struct ScreenshotAction {
513    pub path: String,
514}
515
516#[derive(Debug, Clone, Deserialize)]
517pub struct LogAction {
518    pub message: String,
519}
520
521#[derive(Debug, Clone, Deserialize)]
522pub struct AssertTextAction {
523    pub text: String,
524}
525
526#[derive(Debug, Clone, Deserialize)]
527pub struct AssertUrlAction {
528    pub contains: String,
529}
530
531#[derive(Debug, Clone, Deserialize)]
532pub struct IfTextExistsAction {
533    pub text: String,
534    #[serde(rename = "then")]
535    pub then_actions: Vec<Action>,
536    #[serde(rename = "else", default)]
537    pub else_actions: Vec<Action>,
538}
539
540#[derive(Debug, Clone, Deserialize)]
541pub struct IfSelectorExistsAction {
542    pub selector: String,
543    #[serde(rename = "then")]
544    pub then_actions: Vec<Action>,
545    #[serde(rename = "else", default)]
546    pub else_actions: Vec<Action>,
547}
548
549#[derive(Debug, Clone, Deserialize)]
550pub struct RepeatAction {
551    pub times: u32,
552    pub actions: Vec<Action>,
553}
554
555/// Include another config's actions.
556#[derive(Debug, Clone, Deserialize)]
557pub struct IncludeAction {
558    /// Path to the config file to include.
559    pub path: String,
560
561    /// Parameters to pass to the included config.
562    #[serde(default)]
563    pub params: std::collections::HashMap<String, String>,
564}