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;
52use serde_json::Value;
53
54/// Top-level scenario file, deserialized from TOML.
55#[derive(Debug, Deserialize)]
56pub struct Scenario {
57    /// Global configuration (overridable per test).
58    #[serde(default)]
59    pub config: ScenarioConfig,
60
61    /// Reusable assertion definitions referenced by name in `assert` steps.
62    #[serde(default)]
63    pub definitions: Vec<AssertDefinition>,
64
65    /// Ordered test groups to execute.
66    #[serde(default)]
67    pub test: Vec<TestGroup>,
68}
69
70/// Global scenario configuration with per-test overridable fields.
71#[derive(Debug, Deserialize, Default, Clone)]
72pub struct ScenarioConfig {
73    /// Base URL for relative navigation.
74    pub base_url: Option<String>,
75    /// LLM server base URL.
76    pub llm_url: Option<String>,
77    /// LLM model name.
78    pub llm_model: Option<String>,
79    /// LLM API key (Bearer token).
80    #[serde(default)]
81    pub llm_api_key: Option<String>,
82    /// Custom HTTP headers as JSON key-value pairs.
83    #[serde(default, deserialize_with = "deserialize_headers")]
84    pub llm_headers: HashMap<String, String>,
85    /// Run browser in headless mode.
86    pub browser_headless: Option<bool>,
87    /// HTTP / browser action timeout in seconds.
88    pub timeout_secs: Option<u64>,
89    /// Browser viewport width.
90    pub viewport_width: Option<u32>,
91    /// Browser viewport height.
92    pub viewport_height: Option<u32>,
93    /// Default URL every test auto-navigates to before running its steps.
94    pub start_url: Option<String>,
95    /// Whether to auto-navigate to `start_url` before test steps.
96    /// Disable when a test starts with click-based navigation.
97    #[serde(default = "default_auto_navigate")]
98    pub auto_navigate: bool,
99    /// LLM temperature (0.0–1.0). Lower = more deterministic.
100    #[serde(default = "default_temperature")]
101    pub temperature: f64,
102    /// Enable thinking/reasoning tokens. `None` means the provider default
103    /// is used (no `thinking` key is sent). Set to `true`/`false` to
104    /// explicitly enable or disable.
105    #[serde(default)]
106    pub thinking: Option<bool>,
107    /// Provider-specific model parameters merged into the chat completion
108    /// request body (e.g. `effort = "high"` for Anthropic).
109    #[serde(default, deserialize_with = "deserialize_model_params")]
110    pub model_params: HashMap<String, Value>,
111}
112
113fn deserialize_headers<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
114where
115    D: serde::Deserializer<'de>,
116{
117    let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
118    let Some(json) = raw else {
119        return Ok(HashMap::new());
120    };
121    let serde_json::Value::Object(obj) = json else {
122        return Ok(HashMap::new());
123    };
124    Ok(obj
125        .into_iter()
126        .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_owned())))
127        .collect())
128}
129
130const fn default_auto_navigate() -> bool {
131    true
132}
133
134const fn default_temperature() -> f64 {
135    0.0
136}
137
138fn deserialize_model_params<'de, D>(deserializer: D) -> Result<HashMap<String, Value>, D::Error>
139where
140    D: serde::Deserializer<'de>,
141{
142    #[derive(Deserialize)]
143    #[serde(untagged)]
144    enum Raw {
145        Map(HashMap<String, Value>),
146        Table(HashMap<String, Value>),
147    }
148    let raw: Option<Raw> = Option::deserialize(deserializer)?;
149    Ok(match raw {
150        Some(Raw::Map(m) | Raw::Table(m)) => m,
151        None => HashMap::new(),
152    })
153}
154
155/// Reusable assertion definition referenced by name from `assert` steps.
156///
157/// Definitions can either reference a built-in preset via `preset`, supply a
158/// custom LLM `prompt`, or define a **custom preset** by providing both
159/// `system` and `user_template`. Custom presets support the same template
160/// variables as built-in presets: `{url}`, `{title}`, `{content}`,
161/// `{expected_text}`, `{description}`.
162#[derive(Debug, Deserialize, Clone)]
163pub struct AssertDefinition {
164    /// Unique name used to reference this definition from steps.
165    pub name: String,
166    /// Predefined assertion preset name (e.g. `no_error_on_page`, `text_visible`).
167    #[serde(default)]
168    pub preset: Option<String>,
169    /// Custom LLM prompt for assertion evaluation.
170    #[serde(default)]
171    pub prompt: Option<String>,
172    /// System prompt for a custom preset.
173    #[serde(default)]
174    pub system: Option<String>,
175    /// User template (with `{placeholders}`) for a custom preset.
176    #[serde(default)]
177    pub user_template: Option<String>,
178    /// Text that the `text_visible` preset checks for, or the
179    /// `{expected_text}` placeholder value for custom presets.
180    #[serde(default)]
181    pub assert_text: Option<String>,
182}
183
184/// A group of steps that form a single test scenario.
185#[derive(Debug, Deserialize)]
186pub struct TestGroup {
187    /// Human-readable test name.
188    pub name: String,
189    /// Override the global `start_url` for this test.
190    #[serde(default)]
191    pub start_url: Option<String>,
192    /// Override the global `auto_navigate` for this test.
193    #[serde(default)]
194    pub auto_navigate: Option<bool>,
195    /// Override the global `base_url` for this test.
196    #[serde(default)]
197    pub base_url: Option<String>,
198    /// Override the global `timeout_secs` for this test.
199    #[serde(default)]
200    pub timeout_secs: Option<u64>,
201    /// Override the global `browser_headless` for this test.
202    #[serde(default)]
203    pub browser_headless: Option<bool>,
204    /// Ordered steps to execute.
205    #[serde(default)]
206    pub steps: Vec<TestStep>,
207}
208
209/// A single step in a test. The `kind` field determines which variant is
210/// deserialized and which field constraints apply.
211#[derive(Debug, Deserialize)]
212#[serde(tag = "kind")]
213pub enum TestStep {
214    /// Navigate the browser to a URL.
215    #[serde(rename = "navigate")]
216    Navigate {
217        /// URL to navigate to (absolute, or relative to the test's base URL).
218        url: String,
219        /// Milliseconds to wait after navigation completes.
220        #[serde(default)]
221        wait_after_ms: Option<u64>,
222    },
223
224    /// Click an element described in natural language.
225    #[serde(rename = "click")]
226    Click {
227        /// Natural language description of the element. The LLM resolves this
228        /// to a CSS selector at runtime.
229        target: String,
230        /// Explicit CSS selector override (bypasses LLM resolution).
231        #[serde(default)]
232        selector: Option<String>,
233        /// Milliseconds to wait after the click.
234        #[serde(default)]
235        wait_after_ms: Option<u64>,
236    },
237
238    /// Type text into an input element.
239    #[serde(rename = "type")]
240    Type {
241        /// Natural language description of the target input element.
242        target: String,
243        /// Text to type into the element.
244        text: String,
245        /// Explicit CSS selector override (bypasses LLM resolution).
246        #[serde(default)]
247        selector: Option<String>,
248        /// Milliseconds to wait after typing.
249        #[serde(default)]
250        wait_after_ms: Option<u64>,
251    },
252
253    /// Wait for an element to appear on the page.
254    #[serde(rename = "wait")]
255    Wait {
256        /// Natural language description of the element to wait for.
257        target: String,
258        /// Explicit CSS selector override (bypasses LLM resolution).
259        #[serde(default)]
260        selector: Option<String>,
261        /// Maximum milliseconds to wait (default: 10000).
262        #[serde(default)]
263        timeout_ms: Option<u64>,
264    },
265
266    /// Evaluate an assertion against the current page content.
267    #[serde(rename = "assert")]
268    Assert {
269        /// Reference to a named `[[definitions]]` entry.
270        #[serde(default)]
271        definition: Option<String>,
272        /// Inline predefined assertion preset (e.g. `no_error_on_page`).
273        #[serde(default)]
274        preset: Option<String>,
275        /// Inline custom LLM prompt for assertion evaluation.
276        #[serde(default)]
277        prompt: Option<String>,
278        /// Text that the `text_visible` preset checks for.
279        #[serde(default)]
280        assert_text: Option<String>,
281    },
282
283    /// Take a screenshot of the current page.
284    #[serde(rename = "screenshot")]
285    Screenshot {
286        /// File path to save the screenshot (default: `screenshot.png`).
287        #[serde(default)]
288        path: Option<String>,
289    },
290}