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//! | `agent` | `agent`, `task` | — |
45//! | `mcp` | `server`, `tool`| `args` |
46//!
47//! Assert steps require one of: `definition` (references a named
48//! `[[definitions]]` entry), `preset` (built-in preset name), or `prompt`
49//! (custom LLM evaluation prompt).
50
51use std::collections::HashMap;
52
53use serde::Deserialize;
54use serde_json::Value;
55
56/// Top-level scenario file, deserialized from TOML.
57#[derive(Debug, Deserialize)]
58pub struct Scenario {
59 /// Global configuration (overridable per test).
60 #[serde(default)]
61 pub config: ScenarioConfig,
62
63 /// Reusable assertion definitions referenced by name in `assert` steps.
64 #[serde(default)]
65 pub definitions: Vec<AssertDefinition>,
66
67 /// Ordered test groups to execute.
68 #[serde(default)]
69 pub test: Vec<TestGroup>,
70}
71
72/// Global scenario configuration with per-test overridable fields.
73#[derive(Debug, Deserialize, Default, Clone)]
74pub struct ScenarioConfig {
75 /// Base URL for relative navigation.
76 #[serde(default)]
77 pub base_url: Option<String>,
78 /// LLM server base URL (deprecated; prefer `[config.endpoints]`).
79 #[serde(default)]
80 pub llm_url: Option<String>,
81 /// LLM model name (deprecated; prefer `[config.endpoints]`).
82 #[serde(default)]
83 pub llm_model: Option<String>,
84 /// LLM API key (Bearer token).
85 #[serde(default)]
86 pub llm_api_key: Option<String>,
87 /// Custom HTTP headers as JSON key-value pairs.
88 #[serde(default, deserialize_with = "deserialize_headers")]
89 pub llm_headers: HashMap<String, String>,
90 /// Run browser in headless mode.
91 #[serde(default)]
92 pub browser_headless: Option<bool>,
93 /// HTTP / browser action timeout in seconds.
94 #[serde(default)]
95 pub timeout_secs: Option<u64>,
96 /// Browser viewport width.
97 #[serde(default)]
98 pub viewport_width: Option<u32>,
99 /// Browser viewport height.
100 #[serde(default)]
101 pub viewport_height: Option<u32>,
102 /// Default URL every test auto-navigates to before running its steps.
103 #[serde(default)]
104 pub start_url: Option<String>,
105 /// Whether to auto-navigate to `start_url` before test steps.
106 ///
107 /// Disable when a test starts with click-based navigation.
108 #[serde(default = "default_auto_navigate")]
109 pub auto_navigate: bool,
110 /// LLM temperature (0.0–1.0). Lower = more deterministic.
111 #[serde(default = "default_temperature")]
112 pub temperature: f64,
113 /// Enable thinking/reasoning tokens. `None` means the provider default
114 /// is used (no `thinking` key is sent). Set to `true`/`false` to
115 /// explicitly enable or disable.
116 #[serde(default)]
117 pub thinking: Option<bool>,
118 /// Provider-specific model parameters merged into the chat completion
119 /// request body (e.g. `effort = "high"` for Anthropic).
120 #[serde(default, deserialize_with = "deserialize_model_params")]
121 pub model_params: HashMap<String, Value>,
122 /// Named endpoints (LLM, MCP, A2A agents) with pricing.
123 #[serde(default)]
124 pub endpoints: HashMap<String, EndpointConfig>,
125 /// Global and per-test budgets for cost/token/call limits.
126 #[serde(default)]
127 pub budgets: BudgetsConfig,
128 /// MCP server exposure configuration.
129 #[serde(default)]
130 pub mcp_server: Option<McpServerConfig>,
131 /// A2A agent server exposure configuration.
132 #[serde(default)]
133 pub a2a_server: Option<A2aServerConfig>,
134 /// Whether to continue running the remaining steps of a test after a
135 /// step fails. Default `false` = fail fast: the first failed step ends
136 /// the test and the rest are reported as skipped. Set to `true` to run
137 /// every step (more diagnostics, more LLM cost on broken apps).
138 #[serde(default)]
139 pub continue_on_failure: bool,
140 /// Longest edge (px) of screenshots attached to `screenshot = true`
141 /// assert steps, before they are JPEG-encoded and sent to the vision
142 /// endpoint. Downscaling keeps vision token cost/quality sane.
143 /// Default: 1400.
144 #[serde(default)]
145 pub screenshot_max_dimension: Option<u32>,
146 /// Directory for failure artifacts (screenshots, page snapshots).
147 /// Defaults to `artifacts`.
148 #[serde(default)]
149 pub artifacts_dir: Option<String>,
150}
151
152/// A named endpoint definition with pricing.
153#[derive(Debug, Deserialize, Clone, Default)]
154pub struct EndpointConfig {
155 /// Endpoint type: `llm`, `mcp`, or `a2a`.
156 #[serde(rename = "type")]
157 pub endpoint_type: EndpointType,
158 /// Base URL for the endpoint.
159 #[serde(default)]
160 pub url: Option<String>,
161 /// Model name (LLM endpoints only).
162 #[serde(default)]
163 pub model: Option<String>,
164 /// API key / bearer token.
165 #[serde(default)]
166 pub api_key: Option<String>,
167 /// Custom HTTP headers as JSON key-value pairs.
168 #[serde(default, deserialize_with = "deserialize_headers")]
169 pub headers: HashMap<String, String>,
170 /// Pricing configuration.
171 #[serde(default)]
172 pub pricing: Option<PricingConfig>,
173 /// Task types this endpoint serves by default
174 /// (e.g. `["targeting", "assertion"]`).
175 #[serde(default)]
176 pub default_for: Vec<String>,
177 /// Command to launch an MCP server subprocess (stdio transport).
178 #[serde(default)]
179 pub command: Option<String>,
180 /// Arguments for the MCP server command.
181 #[serde(default)]
182 pub args: Vec<String>,
183 /// Whether this LLM endpoint accepts image parts (vision) in addition
184 /// to text. `assert` steps with `screenshot = true` require a vision
185 /// endpoint.
186 #[serde(default)]
187 pub vision: bool,
188}
189
190/// Type discriminator for endpoint configuration.
191#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Default)]
192#[serde(rename_all = "lowercase")]
193pub enum EndpointType {
194 /// OpenAI-compatible LLM API.
195 #[default]
196 Llm,
197 /// Model Context Protocol server.
198 Mcp,
199 /// Agent-to-Agent protocol agent.
200 A2a,
201}
202
203/// Pricing configuration for an endpoint.
204#[derive(Debug, Deserialize, Clone, Default)]
205pub struct PricingConfig {
206 /// Cost per 1M input tokens (USD).
207 #[serde(default)]
208 pub input_per_1m_tokens: f64,
209 /// Cost per 1M output tokens (USD).
210 #[serde(default)]
211 pub output_per_1m_tokens: f64,
212 /// Flat cost per call (USD), used for MCP/agent endpoints.
213 #[serde(default)]
214 pub per_call: f64,
215}
216
217/// Budget limits for test execution.
218#[derive(Debug, Deserialize, Clone, Default)]
219pub struct BudgetsConfig {
220 /// Global budget across all tests in the scenario.
221 #[serde(default)]
222 pub global: Option<BudgetDef>,
223 /// Default per-test budget. Individual tests can override.
224 #[serde(default)]
225 pub per_test_default: Option<BudgetDef>,
226}
227
228/// A budget definition with limits and enforcement mode.
229#[derive(Debug, Deserialize, Clone)]
230pub struct BudgetDef {
231 /// Maximum cost in USD.
232 #[serde(default)]
233 pub max_cost: Option<f64>,
234 /// Maximum total tokens (input + output).
235 #[serde(default)]
236 pub max_tokens: Option<u64>,
237 /// Maximum number of calls (LLM, MCP, agent combined).
238 #[serde(default)]
239 pub max_calls: Option<u64>,
240 /// Enforcement mode: `hard` (abort) or `soft` (warn and continue).
241 #[serde(default)]
242 pub enforcement: Option<BudgetEnforcement>,
243}
244
245/// Budget enforcement strategy.
246#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
247#[serde(rename_all = "lowercase")]
248pub enum BudgetEnforcement {
249 /// Abort the test or run when budget is exceeded.
250 Hard,
251 /// Log a warning but continue execution.
252 Soft,
253}
254
255/// MCP server exposure configuration.
256#[derive(Debug, Deserialize, Clone)]
257pub struct McpServerConfig {
258 /// Whether to enable the embedded MCP server.
259 #[serde(default)]
260 pub enabled: bool,
261 /// Port to listen on.
262 #[serde(default = "default_mcp_port")]
263 pub port: u16,
264}
265
266const fn default_mcp_port() -> u16 {
267 3000
268}
269
270/// A2A agent server exposure configuration.
271#[derive(Debug, Deserialize, Clone)]
272pub struct A2aServerConfig {
273 /// Whether to enable the embedded A2A agent server.
274 #[serde(default)]
275 pub enabled: bool,
276 /// Port to listen on.
277 #[serde(default = "default_a2a_port")]
278 pub port: u16,
279}
280
281const fn default_a2a_port() -> u16 {
282 3100
283}
284
285fn deserialize_headers<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
286where
287 D: serde::Deserializer<'de>,
288{
289 let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
290 let Some(json) = raw else {
291 return Ok(HashMap::new());
292 };
293 let serde_json::Value::Object(obj) = json else {
294 return Ok(HashMap::new());
295 };
296 Ok(obj
297 .into_iter()
298 .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_owned())))
299 .collect())
300}
301
302const fn default_auto_navigate() -> bool {
303 true
304}
305
306const fn default_temperature() -> f64 {
307 0.0
308}
309
310fn deserialize_model_params<'de, D>(deserializer: D) -> Result<HashMap<String, Value>, D::Error>
311where
312 D: serde::Deserializer<'de>,
313{
314 #[derive(Deserialize)]
315 #[serde(untagged)]
316 enum Raw {
317 Map(HashMap<String, Value>),
318 Table(HashMap<String, Value>),
319 }
320 let raw: Option<Raw> = Option::deserialize(deserializer)?;
321 Ok(match raw {
322 Some(Raw::Map(m) | Raw::Table(m)) => m,
323 None => HashMap::new(),
324 })
325}
326
327/// Reusable assertion definition referenced by name from `assert` steps.
328///
329/// Definitions can either reference a built-in preset via `preset`, supply a
330/// custom LLM `prompt`, or define a **custom preset** by providing both
331/// `system` and `user_template`. Custom presets support the same template
332/// variables as built-in presets: `{url}`, `{title}`, `{content}`,
333/// `{expected_text}`, `{description}`.
334#[derive(Debug, Deserialize, Clone)]
335pub struct AssertDefinition {
336 /// Unique name used to reference this definition from steps.
337 pub name: String,
338 /// Predefined assertion preset name
339 /// (e.g. `no_error_on_page`, `text_visible`).
340 #[serde(default)]
341 pub preset: Option<String>,
342 /// Custom LLM prompt for assertion evaluation.
343 #[serde(default)]
344 pub prompt: Option<String>,
345 /// System prompt for a custom preset.
346 #[serde(default)]
347 pub system: Option<String>,
348 /// User template (with `{placeholders}`) for a custom preset.
349 #[serde(default)]
350 pub user_template: Option<String>,
351 /// Text that the `text_visible` preset checks for, or the
352 /// `{expected_text}` placeholder value for custom presets.
353 #[serde(default)]
354 pub assert_text: Option<String>,
355 /// Agent endpoint to call for this assertion.
356 #[serde(default)]
357 pub agent: Option<String>,
358 /// Agent task template for this assertion.
359 #[serde(default)]
360 pub task_template: Option<String>,
361}
362
363/// A group of steps that form a single test scenario.
364#[derive(Debug, Deserialize)]
365pub struct TestGroup {
366 /// Human-readable test name.
367 pub name: String,
368 /// Override the global `start_url` for this test.
369 #[serde(default)]
370 pub start_url: Option<String>,
371 /// Override the global `auto_navigate` for this test.
372 #[serde(default)]
373 pub auto_navigate: Option<bool>,
374 /// Override the global `base_url` for this test.
375 #[serde(default)]
376 pub base_url: Option<String>,
377 /// Override the global `timeout_secs` for this test.
378 #[serde(default)]
379 pub timeout_secs: Option<u64>,
380 /// Override the global `browser_headless` for this test.
381 #[serde(default)]
382 pub browser_headless: Option<bool>,
383 /// Per-test budget override.
384 #[serde(default)]
385 pub budget: Option<BudgetDef>,
386 /// Endpoint to use for all steps in this test (can be overridden
387 /// per-step).
388 #[serde(default)]
389 pub endpoint: Option<String>,
390 /// Ordered steps to execute.
391 #[serde(default)]
392 pub steps: Vec<TestStep>,
393}
394
395/// A single step in a test. The `kind` field determines which variant is
396/// deserialized and which field constraints apply.
397#[derive(Debug, Deserialize)]
398#[serde(tag = "kind")]
399pub enum TestStep {
400 /// Navigate the browser to a URL.
401 #[serde(rename = "navigate")]
402 Navigate {
403 /// URL to navigate to (absolute, or relative to the test's base
404 /// URL).
405 url: String,
406 /// Milliseconds to wait after navigation completes.
407 #[serde(default)]
408 wait_after_ms: Option<u64>,
409 },
410
411 /// Click an element described in natural language.
412 #[serde(rename = "click")]
413 Click {
414 /// Natural language description of the element. The LLM resolves
415 /// this to a CSS selector at runtime.
416 target: String,
417 /// Explicit CSS selector override (bypasses LLM resolution).
418 #[serde(default)]
419 selector: Option<String>,
420 /// Milliseconds to wait after the click.
421 #[serde(default)]
422 wait_after_ms: Option<u64>,
423 /// Endpoint to use for LLM element targeting.
424 #[serde(default)]
425 endpoint: Option<String>,
426 },
427
428 /// Type text into an input element.
429 #[serde(rename = "type")]
430 Type {
431 /// Natural language description of the target input element.
432 target: String,
433 /// Text to type into the element.
434 text: String,
435 /// Explicit CSS selector override (bypasses LLM resolution).
436 #[serde(default)]
437 selector: Option<String>,
438 /// Milliseconds to wait after typing.
439 #[serde(default)]
440 wait_after_ms: Option<u64>,
441 /// Endpoint to use for LLM element targeting.
442 #[serde(default)]
443 endpoint: Option<String>,
444 },
445
446 /// Wait for an element to appear on the page.
447 #[serde(rename = "wait")]
448 Wait {
449 /// Natural language description of the element to wait for.
450 target: String,
451 /// Explicit CSS selector override (bypasses LLM resolution).
452 #[serde(default)]
453 selector: Option<String>,
454 /// Wait until the page's visible text contains this substring
455 /// (alternative to `selector`; either or both may be set — both are
456 /// required to hold when both are set).
457 #[serde(default)]
458 text: Option<String>,
459 /// Maximum milliseconds to wait (default: 10000).
460 #[serde(default)]
461 timeout_ms: Option<u64>,
462 /// Endpoint to use for LLM element targeting.
463 #[serde(default)]
464 endpoint: Option<String>,
465 },
466
467 /// Evaluate an assertion against the current page content.
468 #[serde(rename = "assert")]
469 Assert {
470 /// Reference to a named `[[definitions]]` entry.
471 #[serde(default)]
472 definition: Option<String>,
473 /// Inline predefined assertion preset (e.g. `no_error_on_page`).
474 #[serde(default)]
475 preset: Option<String>,
476 /// Inline custom LLM prompt for assertion evaluation.
477 #[serde(default)]
478 prompt: Option<String>,
479 /// Text that the `text_visible` preset checks for.
480 #[serde(default)]
481 assert_text: Option<String>,
482 /// Endpoint to use for this assertion's LLM call.
483 #[serde(default)]
484 endpoint: Option<String>,
485 /// Attach a screenshot of the current viewport to the assertion so
486 /// the LLM can evaluate visuals (overlaps, clipping, layout).
487 /// Requires the resolved endpoint to declare `vision = true`.
488 #[serde(default)]
489 screenshot: bool,
490 },
491
492 /// Take a screenshot of the current page.
493 #[serde(rename = "screenshot")]
494 Screenshot {
495 /// File path to save the screenshot (default: `screenshot.png`).
496 #[serde(default)]
497 path: Option<String>,
498 },
499
500 /// Call an A2A agent with a task.
501 #[serde(rename = "agent")]
502 Agent {
503 /// Name of the agent endpoint to call.
504 agent: String,
505 /// Task description / prompt for the agent.
506 task: String,
507 /// Optional definition name with a task template.
508 #[serde(default)]
509 definition: Option<String>,
510 },
511
512 /// Call an MCP server tool.
513 #[serde(rename = "mcp")]
514 Mcp {
515 /// Name of the MCP server endpoint.
516 server: String,
517 /// Tool name to invoke on the server.
518 tool: String,
519 /// Tool arguments as JSON.
520 #[serde(default)]
521 args: Option<serde_json::Value>,
522 },
523}