Skip to main content

oxicode_agent/tools/browse/
browse_script_tool.rs

1//! Multi-step browser automation via YAML scripts.
2//!
3//! Parses a YAML sequence of steps and executes them in a single tab.
4//! Requires the `native-browser` feature (serde_yaml + oxibrowser-core).
5
6#![allow(clippy::useless_conversion, missing_docs)]
7
8use super::config::BrowseConfig;
9use super::engine::BrowserEngine;
10use super::helpers;
11use super::tab_guard::TabGuard;
12use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError};
13use async_trait::async_trait;
14use parking_lot::Mutex;
15use serde::Deserialize;
16use serde_json::{Value, json};
17use std::path::Path;
18use std::sync::Arc;
19use tokio::sync::oneshot;
20
21// ── Step definitions ──────────────────────────────────────────────────────────
22
23/// A single step in a browse script.
24#[allow(missing_docs)] // Each variant is self-documenting via its label.
25#[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum Step {
28    /// Navigate to a URL.
29    Goto { url: String },
30    /// Go back in browser history.
31    Back,
32    /// Go forward in browser history.
33    Forward,
34    /// Reload the current page.
35    Reload,
36    /// Click an element.
37    Click { selector: String },
38    /// Fill an input element (sets value directly).
39    Fill { selector: String, value: String },
40    /// Type text character by character.
41    Type { selector: String, value: String },
42    /// Clear an input element.
43    Clear { selector: String },
44    /// Check a checkbox.
45    Check { selector: String },
46    /// Uncheck a checkbox.
47    Uncheck { selector: String },
48    /// Select an option in a `<select>`.
49    Select { selector: String, value: String },
50    /// Press a keyboard combo.
51    Press { combo: String },
52    /// Scroll down by N pixels.
53    Scroll { pixels: u32 },
54    /// Wait for an element to appear.
55    Wait { selector: String },
56    /// Evaluate JavaScript and optionally store the result.
57    Evaluate { expr: String },
58    /// Extract text content from elements matching a selector.
59    Extract {
60        selector: String,
61        #[serde(default)]
62        all: bool,
63    },
64    /// Get the full page content as markdown.
65    Content,
66    /// Take a screenshot.
67    Screenshot,
68    /// Set a variable (echo for debugging).
69    Set { key: String, value: String },
70    /// Echo a value (for debugging).
71    Echo { message: String },
72    /// Sleep for N milliseconds.
73    Sleep { ms: u64 },
74}
75
76/// Result of executing a script.
77pub struct ScriptResult {
78    pub outputs: Vec<String>,
79    pub screenshot: Option<Vec<u8>>,
80    pub variables: std::collections::HashMap<String, String>,
81}
82
83// ── Parsing ───────────────────────────────────────────────────────────────────
84
85fn parse_steps(yaml: &str) -> Result<Vec<Step>, ToolError> {
86    // Accept both simple format (list of maps) and explicit format
87    // with a "steps:" key. The simple format is a bare sequence, the
88    // explicit format is `{ steps: [...] }`.
89    let doc: serde_yaml::Value =
90        serde_yaml::from_str(yaml).map_err(|e| format!("Invalid YAML: {}", e))?;
91
92    let raw_seq = match &doc {
93        serde_yaml::Value::Sequence(_) => doc.clone(),
94        serde_yaml::Value::Mapping(map) => {
95            // Look up "steps" key (serde_yaml 0.9 doesn't implement
96            // Index<&str> on Mapping, so iterate).
97            let mut found: Option<&serde_yaml::Value> = None;
98            for (k, v) in map.iter() {
99                if let serde_yaml::Value::String(s) = k
100                    && s == "steps"
101                {
102                    found = Some(v);
103                    break;
104                }
105            }
106            match found {
107                Some(v) => v.clone(),
108                None => {
109                    return Err("Missing 'steps' key in YAML document".into());
110                }
111            }
112        }
113        _ => return Err("YAML document must be a sequence or a map with a 'steps' key".into()),
114    };
115
116    // raw_seq is a sequence of mappings, each containing a single
117    // key-value pair that names the variant. E.g.:
118    //   - goto: "url"        → Step::Goto { url }
119    //   - fill: { ... }      → Step::Fill { selector, value }
120    //   - screenshot: {}     → Step::Screenshot
121    //
122    // The single value can be either a string (shorthand for a
123    // one-field struct variant), a mapping (multi-field struct variant),
124    // or null (unit variant).
125    let yaml_seq = raw_seq
126        .as_sequence()
127        .ok_or_else(|| "steps must be a YAML sequence".to_string())?;
128
129    let mut steps = Vec::with_capacity(yaml_seq.len());
130    for (i, item) in yaml_seq.iter().enumerate() {
131        let mapping = item
132            .as_mapping()
133            .ok_or_else(|| format!("step {} is not a YAML mapping", i))?;
134        if mapping.len() != 1 {
135            return Err(format!(
136                "step {} must have exactly one key (the variant name), got {}",
137                i,
138                mapping.len()
139            ));
140        }
141        // SAFETY: `mapping.len() == 1` is checked above, so `next()` must
142        // return `Some`. Infallible by construction.
143        #[allow(clippy::expect_used)]
144        let (variant_key, payload) = mapping
145            .iter()
146            .next()
147            .expect("mapping.len() == 1 checked above");
148        let variant = variant_key
149            .as_str()
150            .ok_or_else(|| format!("step {} variant name is not a string", i))?;
151        let step = step_from_yaml_payload(variant, payload)
152            .map_err(|e| format!("step {} ({}): {}", i, variant, e))?;
153        steps.push(step);
154    }
155
156    Ok(steps)
157}
158
159/// Build a `Step` from a YAML variant name + payload value.
160///
161/// `payload` may be:
162/// - A string → shorthand for `{ <only_field>: <string> }`.
163/// - A mapping → used as-is.
164/// - A null → unit variant (no fields).
165fn step_from_yaml_payload(variant: &str, payload: &serde_yaml::Value) -> Result<Step, String> {
166    use serde_yaml::Value as Y;
167
168    /// Build a JSON object `{ <variant>: <fields> }` and let serde
169    /// do the variant dispatch. For single-field struct variants
170    /// with a YAML string payload, the field name is inferred by
171    /// inspecting the variant's expected field.
172    let wrapper = match payload {
173        Y::Null => serde_json::json!({ variant: {} }),
174        Y::Mapping(m) if m.is_empty() => {
175            // `screenshot: {}` — unit variant with no fields.
176            serde_json::json!({ variant: null })
177        }
178        Y::String(s) => {
179            // Try common single-field variants first; if none match,
180            // let serde report the error.
181            let single_field = single_field_struct_variant(variant)
182                .ok_or_else(|| format!("variant '{}' has no single-field shorthand", variant))?;
183            serde_json::json!({ variant: { single_field: s } })
184        }
185        _ => {
186            let payload_json = yaml_to_json_recursive(payload)?;
187            serde_json::json!({ variant: payload_json })
188        }
189    };
190    serde_json::from_value(wrapper).map_err(|e| e.to_string())
191}
192
193/// Return the field name for variants that have exactly one String field,
194/// enabling YAML shorthand `- goto: "url"` for `- goto: { url: "url" }`.
195fn single_field_struct_variant(variant: &str) -> Option<&'static str> {
196    match variant {
197        "goto" => Some("url"),
198        "click" => Some("selector"),
199        "fill" | "fill_" => Some("selector"), // not actually used; fill has 2 fields
200        "type" | "type_" => Some("selector"),
201        "clear" => Some("selector"),
202        "check" | "uncheck" => Some("selector"),
203        "select" => Some("selector"),
204        "press" => Some("combo"),
205        "wait" => Some("selector"),
206        "evaluate" => Some("expr"),
207        "extract" => Some("selector"),
208        "set" => Some("key"),
209        "echo" => Some("message"),
210        _ => None,
211    }
212}
213
214fn yaml_to_json_recursive(v: &serde_yaml::Value) -> Result<serde_json::Value, ToolError> {
215    use serde_yaml::Value as Y;
216    match v {
217        Y::Null => Ok(serde_json::Value::Null),
218        Y::Bool(b) => Ok(serde_json::Value::Bool(*b)),
219        Y::Number(n) => {
220            if let Some(i) = n.as_i64() {
221                Ok(serde_json::Value::Number(i.into()))
222            } else if let Some(u) = n.as_u64() {
223                Ok(serde_json::Value::Number(u.into()))
224            } else if let Some(f) = n.as_f64() {
225                serde_json::Number::from_f64(f)
226                    .map(serde_json::Value::Number)
227                    .ok_or_else(|| "non-finite number".to_string())
228            } else {
229                Err("unsupported number type".to_string())
230            }
231        }
232        Y::String(s) => Ok(serde_json::Value::String(s.clone())),
233        Y::Sequence(items) => {
234            let arr: Result<Vec<serde_json::Value>, ToolError> =
235                items.iter().map(yaml_to_json_recursive).collect();
236            Ok(serde_json::Value::Array(arr?))
237        }
238        Y::Mapping(map) => {
239            let mut obj = serde_json::Map::new();
240            for (k, v) in map.iter() {
241                let key = k
242                    .as_str()
243                    .ok_or_else(|| "non-string mapping key".to_string())?
244                    .to_string();
245                obj.insert(key, yaml_to_json_recursive(v)?);
246            }
247            Ok(serde_json::Value::Object(obj))
248        }
249        Y::Tagged(t) => yaml_to_json_recursive(&t.value),
250    }
251}
252
253// ── Execution ─────────────────────────────────────────────────────────────────
254
255async fn execute_steps(
256    tab: &dyn super::engine::BrowserTab,
257    steps: &[Step],
258    config: &BrowseConfig,
259    deadline: tokio::time::Instant,
260    progress_cb: Option<&crate::tools::ProgressCallback>,
261) -> Result<ScriptResult, ToolError> {
262    let mut result = ScriptResult {
263        outputs: Vec::new(),
264        screenshot: None,
265        variables: std::collections::HashMap::new(),
266    };
267
268    for (i, step) in steps.iter().enumerate() {
269        if tokio::time::Instant::now() > deadline {
270            return Err(format!("Script timed out at step {} of {}", i + 1, steps.len()).into());
271        }
272
273        if i >= config.max_script_steps {
274            return Err(format!(
275                "Exceeded maximum script steps ({})",
276                config.max_script_steps
277            )
278            .into());
279        }
280
281        // Emit step-level progress so the UI can show "3/10 Clicking element…"
282        if let Some(cb) = progress_cb {
283            cb(format!("[{}/{}] {}", i + 1, steps.len(), step_label(step)));
284        }
285
286        execute_single_step(tab, step, &mut result, config).await?;
287    }
288
289    Ok(result)
290}
291
292/// Human-readable label for a script step (for progress reporting).
293fn step_label(step: &Step) -> &'static str {
294    match step {
295        Step::Goto { .. } => "Navigating",
296        Step::Click { .. } => "Clicking element",
297        Step::Fill { .. } => "Filling input",
298        Step::Type { .. } => "Typing text",
299        Step::Clear { .. } => "Clearing input",
300        Step::Check { .. } => "Checking checkbox",
301        Step::Uncheck { .. } => "Unchecking checkbox",
302        Step::Select { .. } => "Selecting option",
303        Step::Press { .. } => "Pressing key",
304        Step::Scroll { .. } => "Scrolling",
305        Step::Wait { .. } => "Waiting for element",
306        Step::Evaluate { .. } => "Evaluating JavaScript",
307        Step::Extract { .. } => "Extracting data",
308        Step::Content => "Reading page content",
309        Step::Screenshot => "Taking screenshot",
310        Step::Set { .. } => "Setting variable",
311        Step::Echo { .. } => "Echo",
312        Step::Sleep { .. } => "Sleeping",
313        Step::Back => "Going back",
314        Step::Forward => "Going forward",
315        Step::Reload => "Reloading page",
316    }
317}
318
319async fn execute_single_step(
320    tab: &dyn super::engine::BrowserTab,
321    step: &Step,
322    result: &mut ScriptResult,
323    config: &BrowseConfig,
324) -> Result<(), ToolError> {
325    match step {
326        Step::Goto { url } => {
327            tab.goto(url).await.map_err(|e| e.to_string())?;
328        }
329        Step::Click { selector } => {
330            tab.click(selector).await.map_err(|e| e.to_string())?;
331        }
332        Step::Fill { selector, value } => {
333            tab.fill(selector, value).await.map_err(|e| e.to_string())?;
334        }
335        Step::Type { selector, value } => {
336            tab.type_(selector, value)
337                .await
338                .map_err(|e| e.to_string())?;
339        }
340        Step::Clear { selector } => {
341            // Clear by filling with empty string
342            tab.fill(selector, "").await.map_err(|e| e.to_string())?;
343        }
344        Step::Check { selector } => {
345            let js = helpers::js_check(selector);
346            tab.evaluate(&js).await.map_err(|e| e.to_string())?;
347        }
348        Step::Uncheck { selector } => {
349            let js = helpers::js_uncheck(selector);
350            tab.evaluate(&js).await.map_err(|e| e.to_string())?;
351        }
352        Step::Select { selector, value } => {
353            if value.is_empty() {
354                return Err("Select step requires a non-empty value".into());
355            }
356            let js = helpers::js_set_select_value(selector, value);
357            tab.evaluate(&js).await.map_err(|e| e.to_string())?;
358        }
359        Step::Press { combo } => {
360            tab.press(combo).await.map_err(|e| e.to_string())?;
361        }
362        Step::Scroll { pixels } => {
363            let js = format!("window.scrollBy(0, {})", pixels);
364            tab.evaluate(&js).await.map_err(|e| e.to_string())?;
365        }
366        Step::Wait { selector } => {
367            tab.wait_for(selector, config.default_wait_timeout_ms)
368                .await
369                .map_err(|e| e.to_string())?;
370        }
371        Step::Evaluate { expr } => {
372            let value = tab.evaluate(expr).await.map_err(|e| e.to_string())?;
373            let text = match value {
374                Value::String(s) => s,
375                other => serde_json::to_string(&other).unwrap_or_default(),
376            };
377            result.outputs.push(text);
378        }
379        Step::Extract { selector, all } => {
380            let texts = tab.query_all(selector).await.map_err(|e| e.to_string())?;
381            let texts = if *all {
382                texts
383            } else {
384                texts.into_iter().take(1).collect()
385            };
386            result.outputs.push(texts.join("\n"));
387        }
388        Step::Content => {
389            let page = tab.content().await.map_err(|e| e.to_string())?;
390            result.outputs.push(page.markdown);
391        }
392        Step::Screenshot => {
393            let png = tab
394                .screenshot(config.screenshot_width)
395                .await
396                .map_err(|e| e.to_string())?;
397            result.screenshot = Some(png);
398        }
399        Step::Set { key, value } => {
400            result.variables.insert(key.clone(), value.clone());
401        }
402        Step::Echo { message } => {
403            result.outputs.push(message.clone());
404        }
405        Step::Sleep { ms } => {
406            tokio::time::sleep(std::time::Duration::from_millis(*ms)).await;
407        }
408        // History navigation — best-effort via JS
409        Step::Back => {
410            let _ = tab.evaluate("history.back()").await;
411        }
412        Step::Forward => {
413            let _ = tab.evaluate("history.forward()").await;
414        }
415        Step::Reload => {
416            let _ = tab.evaluate("location.reload()").await;
417        }
418    }
419    Ok(())
420}
421
422// ── BrowseScriptTool ──────────────────────────────────────────────────────────
423
424/// Multi-step browser automation tool.
425pub struct BrowseScriptTool {
426    engine: Arc<dyn BrowserEngine>,
427    config: BrowseConfig,
428    /// Shared callback management (progress + browse progress).
429    callbacks: super::callback_mixin::BrowseCallbacks,
430    /// Shared slot for the current tab's ID.
431    tab_id_slot: Mutex<Arc<parking_lot::Mutex<Option<uuid::Uuid>>>>,
432}
433
434impl BrowseScriptTool {
435    /// Create with the given engine and default config.
436    pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
437        Self {
438            engine,
439            config: BrowseConfig::default(),
440            callbacks: super::callback_mixin::BrowseCallbacks::new(),
441            tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
442        }
443    }
444
445    /// Create with custom configuration.
446    pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
447        Self {
448            engine,
449            config,
450            callbacks: super::callback_mixin::BrowseCallbacks::new(),
451            tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
452        }
453    }
454}
455
456#[async_trait]
457impl AgentTool for BrowseScriptTool {
458    fn name(&self) -> &str {
459        "browse_script"
460    }
461
462    fn label(&self) -> &str {
463        "Browser Script"
464    }
465
466    fn description(&self) -> &str {
467        "Run a multi-step browser automation script in YAML format. \
468         Supports: goto, click, fill, type, press, wait, extract, evaluate, \
469         check, uncheck, select, scroll, screenshot, content, sleep."
470    }
471
472    fn on_progress(&self, callback: crate::tools::ProgressCallback) {
473        self.callbacks.store_progress(callback);
474    }
475
476    fn on_browse_progress(&self, callback: Arc<dyn Fn(super::BrowseProgress) + Send + Sync>) {
477        self.callbacks.store_browse(callback);
478    }
479
480    fn set_tab_id_slot(&self, slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>>) {
481        *self.tab_id_slot.lock() = slot;
482    }
483
484    fn current_tab_id(&self) -> Option<uuid::Uuid> {
485        *self.tab_id_slot.lock().lock()
486    }
487
488    fn parameters_schema(&self) -> Value {
489        json!({
490            "type": "object",
491            "properties": {
492                "script": {
493                    "type": "string",
494                    "description": "YAML script (inline or path to .yaml file)"
495                },
496                "timeout": {
497                    "type": "integer",
498                    "default": 60,
499                    "description": "Maximum execution time in seconds"
500                }
501            },
502            "required": ["script"]
503        })
504    }
505
506    async fn execute(
507        &self,
508        _tool_call_id: &str,
509        params: Value,
510        _signal: Option<oneshot::Receiver<()>>,
511        _ctx: &ToolContext,
512    ) -> Result<AgentToolResult, ToolError> {
513        let script_input = params["script"]
514            .as_str()
515            .ok_or_else(|| "Missing required parameter: script".to_string())?;
516
517        let timeout_secs = params["timeout"].as_u64().unwrap_or(60);
518        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
519
520        // Load script from file if it's a path
521        let yaml = if Path::new(script_input).exists() {
522            std::fs::read_to_string(script_input)
523                .map_err(|e| format!("Failed to read script file: {}", e))?
524        } else {
525            script_input.to_string()
526        };
527
528        let steps = parse_steps(&yaml)?;
529        if steps.is_empty() {
530            return Err("Script contains no steps".into());
531        }
532
533        tracing::info!(steps = steps.len(), "executing browse script");
534
535        // Take the pending progress callback for step-level emission.
536        // The browse callback is registered on the registry separately.
537        let progress_cb = self.callbacks.take_progress();
538
539        // Open one tab for the entire script
540        let raw_tab = self
541            .engine
542            .new_tab()
543            .await
544            .map_err(|e| format!("Failed to open browser tab: {}", e))?;
545
546        let tab_id = raw_tab.tab_id();
547        *self.tab_id_slot.lock().lock() = Some(tab_id);
548
549        // Register progress callback on the registry for BrowserEvent routing.
550        if let Some(ref cb) = progress_cb {
551            let registry = self.engine.callback_registry();
552            registry.set(tab_id, cb.clone());
553        }
554        // Register browse callback (still pending, not taken by take_progress).
555        self.callbacks
556            .register_browse_on_registry(tab_id, self.engine.callback_registry().as_ref());
557
558        let guard = TabGuard::new(raw_tab);
559
560        let script_result = execute_steps(
561            guard.tab(),
562            &steps,
563            &self.config,
564            deadline,
565            progress_cb.as_ref(),
566        )
567        .await?;
568
569        // Build output
570        let mut output_parts = Vec::new();
571        if !script_result.outputs.is_empty() {
572            output_parts.push(script_result.outputs.join("\n"));
573        }
574
575        let metadata = json!({
576            "steps_executed": steps.len(),
577            "variables": script_result.variables,
578        });
579
580        let mut result = AgentToolResult::success(output_parts.join("\n")).with_metadata(metadata);
581
582        // Attach screenshot if captured
583        if let Some(png) = script_result.screenshot {
584            let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
585            let img =
586                oxicode_ai::ContentBlock::Image(oxicode_ai::ImageContent::new(b64, "image/png"));
587            result = result.with_content_blocks(vec![img]);
588        }
589
590        guard.close().await;
591        *self.tab_id_slot.lock().lock() = None;
592        Ok(result)
593    }
594}
595
596// ── Tests ─────────────────────────────────────────────────────────────────────
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn parse_simple_goto() {
604        let yaml = r##"
605steps:
606  - goto: "https://example.com"
607  - click: "button.submit"
608  - fill:
609      selector: "#search"
610      value: "rust"
611"##;
612        let steps = parse_steps(yaml).unwrap();
613        assert_eq!(steps.len(), 3);
614        assert!(matches!(&steps[0], Step::Goto { url } if url == "https://example.com"));
615        assert!(matches!(&steps[1], Step::Click { selector } if selector == "button.submit"));
616        assert!(
617            matches!(&steps[2], Step::Fill { selector, value } if selector == "#search" && value == "rust")
618        );
619    }
620
621    #[test]
622    fn parse_extract_step() {
623        let yaml = r#"
624steps:
625  - extract:
626      selector: ".result h3"
627      all: true
628"#;
629        let steps = parse_steps(yaml).unwrap();
630        assert_eq!(steps.len(), 1);
631        assert!(
632            matches!(&steps[0], Step::Extract { selector, all } if selector == ".result h3" && *all)
633        );
634    }
635
636    #[test]
637    fn parse_evaluate_step() {
638        let yaml = r#"
639steps:
640  - evaluate:
641      expr: "document.title"
642"#;
643        let steps = parse_steps(yaml).unwrap();
644        assert!(matches!(&steps[0], Step::Evaluate { expr } if expr == "document.title"));
645    }
646
647    #[test]
648    fn parse_screenshot_step() {
649        let yaml = r#"
650steps:
651  - goto: "https://example.com"
652  - screenshot: {}
653"#;
654        let steps = parse_steps(yaml).unwrap();
655        assert!(matches!(&steps[1], Step::Screenshot));
656    }
657
658    #[test]
659    fn parse_wait_step() {
660        let yaml = r#"
661steps:
662  - wait:
663      selector: ".loaded"
664"#;
665        let steps = parse_steps(yaml).unwrap();
666        assert!(matches!(&steps[0], Step::Wait { selector } if selector == ".loaded"));
667    }
668
669    #[test]
670    fn parse_press_step() {
671        let yaml = r#"
672steps:
673  - press:
674      combo: "Enter"
675"#;
676        let steps = parse_steps(yaml).unwrap();
677        assert!(matches!(&steps[0], Step::Press { combo } if combo == "Enter"));
678    }
679
680    #[test]
681    fn parse_scroll_step() {
682        let yaml = r#"
683steps:
684  - scroll:
685      pixels: 500
686"#;
687        let steps = parse_steps(yaml).unwrap();
688        assert!(matches!(&steps[0], Step::Scroll { pixels } if *pixels == 500));
689    }
690
691    #[test]
692    fn parse_select_step() {
693        let yaml = r##"
694steps:
695  - select:
696      selector: "#country"
697      value: "US"
698"##;
699        let steps = parse_steps(yaml).unwrap();
700        assert!(
701            matches!(&steps[0], Step::Select { selector, value } if selector == "#country" && value == "US")
702        );
703    }
704
705    #[test]
706    fn parse_check_uncheck_steps() {
707        let yaml = r##"
708steps:
709  - check:
710      selector: "#agree"
711  - uncheck:
712      selector: "#newsletter"
713"##;
714        let steps = parse_steps(yaml).unwrap();
715        assert!(matches!(&steps[0], Step::Check { selector } if selector == "#agree"));
716        assert!(matches!(&steps[1], Step::Uncheck { selector } if selector == "#newsletter"));
717    }
718
719    #[test]
720    fn parse_empty_script_returns_error() {
721        let yaml = r#"
722steps: []
723"#;
724        let steps = parse_steps(yaml).unwrap();
725        assert!(steps.is_empty());
726    }
727
728    #[test]
729    fn test_js_helpers() {
730        // Verify JS helpers produce valid JS strings
731        let sel_js = helpers::js_set_select_value("#country", "US");
732        assert!(sel_js.contains("#country"));
733        assert!(sel_js.contains("US"));
734
735        let check_js = helpers::js_check("#agree");
736        assert!(check_js.contains("#agree"));
737        assert!(check_js.contains("!el.checked"));
738
739        let uncheck_js = helpers::js_uncheck("#newsletter");
740        assert!(uncheck_js.contains("#newsletter"));
741        assert!(uncheck_js.contains("el.checked"));
742    }
743}