Skip to main content

llm_browser_testkit/
scenario.rs

1//! Scenario types for human-readable browser test case definitions.
2//!
3//! Scenarios are written in [TOML](https://toml.io) and describe groups of
4//! browser interaction tests with reusable assertion definitions and
5//! configurable test-level overrides.
6//!
7//! # Structure
8//!
9//! ```toml
10//! [config]                         # Global defaults
11//! start_url = "/dashboard"
12//!
13//! [[definitions]]                  # Reusable assertion definitions
14//! name = "no_errors"
15//! preset = "no_error_on_page"
16//!
17//! [[test]]                         # Test group
18//! name = "Dashboard Smoke"
19//! start_url = "/dashboard"         # Override global start_url (optional)
20//!
21//! [[test.steps]]                   # Ordered steps — the `kind` field
22//! kind = "navigate"                # determines which other fields apply
23//! url = "/dashboard"
24//!
25//! [[test.steps]]
26//! kind = "click"
27//! target = "the Login button"      # Natural language — LLM resolves to selector
28//!
29//! [[test.steps]]
30//! kind = "assert"
31//! definition = "no_errors"
32//! ```
33//!
34//! ## Step Kinds
35//!
36//! | `kind`        | Required fields  | Optional fields                          |
37//! |---------------|-----------------|------------------------------------------|
38//! | `navigate`    | `url`           | `wait_after_ms`                          |
39//! | `click`       | `target`        | `selector`, `wait_after_ms`              |
40//! | `type`        | `target`, `text`| `selector`, `wait_after_ms`              |
41//! | `wait`        | `target`        | `selector`, `timeout_ms`                 |
42//! | `assert`      | *one of below*  | —                                        |
43//! | `screenshot`  | —               | `path`                                   |
44//!
45//! Assert steps require one of: `definition` (references a named
46//! `[[definitions]]` entry), `preset` (built-in preset name), or `prompt`
47//! (custom LLM evaluation prompt).
48
49use std::collections::HashMap;
50
51use serde::Deserialize;
52
53/// Top-level scenario file, deserialized from TOML.
54#[derive(Debug, Deserialize)]
55pub struct Scenario {
56    /// Global configuration (overridable per test).
57    #[serde(default)]
58    pub config: ScenarioConfig,
59
60    /// Reusable assertion definitions referenced by name in `assert` steps.
61    #[serde(default)]
62    pub definitions: Vec<AssertDefinition>,
63
64    /// Ordered test groups to execute.
65    #[serde(default)]
66    pub test: Vec<TestGroup>,
67}
68
69/// Global scenario configuration with per-test overridable fields.
70#[derive(Debug, Deserialize, Default, Clone)]
71pub struct ScenarioConfig {
72    /// Base URL for relative navigation.
73    pub base_url: Option<String>,
74    /// LLM server base URL.
75    pub llm_url: Option<String>,
76    /// LLM model name.
77    pub llm_model: Option<String>,
78    /// LLM API key (Bearer token).
79    #[serde(default)]
80    pub llm_api_key: Option<String>,
81    /// Custom HTTP headers as JSON key-value pairs.
82    #[serde(default, deserialize_with = "deserialize_headers")]
83    pub llm_headers: HashMap<String, String>,
84    /// Run browser in headless mode.
85    pub browser_headless: Option<bool>,
86    /// HTTP / browser action timeout in seconds.
87    pub timeout_secs: Option<u64>,
88    /// Browser viewport width.
89    pub viewport_width: Option<u32>,
90    /// Browser viewport height.
91    pub viewport_height: Option<u32>,
92    /// Default URL every test auto-navigates to before running its steps.
93    pub start_url: Option<String>,
94    /// Whether to auto-navigate to `start_url` before test steps.
95    /// Disable when a test starts with click-based navigation.
96    #[serde(default = "default_auto_navigate")]
97    pub auto_navigate: bool,
98    /// LLM temperature (0.0–1.0). Lower = more deterministic.
99    #[serde(default = "default_temperature")]
100    pub temperature: f64,
101    /// Enable thinking/reasoning mode (for models that support it).
102    #[serde(default = "default_thinking")]
103    pub thinking: bool,
104}
105
106fn deserialize_headers<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
107where
108    D: serde::Deserializer<'de>,
109{
110    let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
111    let Some(json) = raw else {
112        return Ok(HashMap::new());
113    };
114    let serde_json::Value::Object(obj) = json else {
115        return Ok(HashMap::new());
116    };
117    Ok(obj
118        .into_iter()
119        .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_owned())))
120        .collect())
121}
122
123const fn default_auto_navigate() -> bool {
124    true
125}
126
127const fn default_temperature() -> f64 {
128    0.0
129}
130
131const fn default_thinking() -> bool {
132    false
133}
134
135/// Reusable assertion definition referenced by name from `assert` steps.
136#[derive(Debug, Deserialize, Clone)]
137pub struct AssertDefinition {
138    /// Unique name used to reference this definition from steps.
139    pub name: String,
140    /// Predefined assertion preset name (e.g. `no_error_on_page`, `text_visible`).
141    #[serde(default)]
142    pub preset: Option<String>,
143    /// Custom LLM prompt for assertion evaluation.
144    #[serde(default)]
145    pub prompt: Option<String>,
146    /// Text that the `text_visible` preset checks for.
147    #[serde(default)]
148    pub assert_text: Option<String>,
149}
150
151/// A group of steps that form a single test scenario.
152#[derive(Debug, Deserialize)]
153pub struct TestGroup {
154    /// Human-readable test name.
155    pub name: String,
156    /// Override the global `start_url` for this test.
157    #[serde(default)]
158    pub start_url: Option<String>,
159    /// Override the global `auto_navigate` for this test.
160    #[serde(default)]
161    pub auto_navigate: Option<bool>,
162    /// Override the global `base_url` for this test.
163    #[serde(default)]
164    pub base_url: Option<String>,
165    /// Override the global `timeout_secs` for this test.
166    #[serde(default)]
167    pub timeout_secs: Option<u64>,
168    /// Override the global `browser_headless` for this test.
169    #[serde(default)]
170    pub browser_headless: Option<bool>,
171    /// Ordered steps to execute.
172    #[serde(default)]
173    pub steps: Vec<TestStep>,
174}
175
176/// A single step in a test. The `kind` field determines which variant is
177/// deserialized and which field constraints apply.
178#[derive(Debug, Deserialize)]
179#[serde(tag = "kind")]
180pub enum TestStep {
181    /// Navigate the browser to a URL.
182    #[serde(rename = "navigate")]
183    Navigate {
184        /// URL to navigate to (absolute, or relative to the test's base URL).
185        url: String,
186        /// Milliseconds to wait after navigation completes.
187        #[serde(default)]
188        wait_after_ms: Option<u64>,
189    },
190
191    /// Click an element described in natural language.
192    #[serde(rename = "click")]
193    Click {
194        /// Natural language description of the element. The LLM resolves this
195        /// to a CSS selector at runtime.
196        target: String,
197        /// Explicit CSS selector override (bypasses LLM resolution).
198        #[serde(default)]
199        selector: Option<String>,
200        /// Milliseconds to wait after the click.
201        #[serde(default)]
202        wait_after_ms: Option<u64>,
203    },
204
205    /// Type text into an input element.
206    #[serde(rename = "type")]
207    Type {
208        /// Natural language description of the target input element.
209        target: String,
210        /// Text to type into the element.
211        text: String,
212        /// Explicit CSS selector override (bypasses LLM resolution).
213        #[serde(default)]
214        selector: Option<String>,
215        /// Milliseconds to wait after typing.
216        #[serde(default)]
217        wait_after_ms: Option<u64>,
218    },
219
220    /// Wait for an element to appear on the page.
221    #[serde(rename = "wait")]
222    Wait {
223        /// Natural language description of the element to wait for.
224        target: String,
225        /// Explicit CSS selector override (bypasses LLM resolution).
226        #[serde(default)]
227        selector: Option<String>,
228        /// Maximum milliseconds to wait (default: 10000).
229        #[serde(default)]
230        timeout_ms: Option<u64>,
231    },
232
233    /// Evaluate an assertion against the current page content.
234    #[serde(rename = "assert")]
235    Assert {
236        /// Reference to a named `[[definitions]]` entry.
237        #[serde(default)]
238        definition: Option<String>,
239        /// Inline predefined assertion preset (e.g. `no_error_on_page`).
240        #[serde(default)]
241        preset: Option<String>,
242        /// Inline custom LLM prompt for assertion evaluation.
243        #[serde(default)]
244        prompt: Option<String>,
245        /// Text that the `text_visible` preset checks for.
246        #[serde(default)]
247        assert_text: Option<String>,
248    },
249
250    /// Take a screenshot of the current page.
251    #[serde(rename = "screenshot")]
252    Screenshot {
253        /// File path to save the screenshot (default: `screenshot.png`).
254        #[serde(default)]
255        path: Option<String>,
256    },
257}