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}