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 /// Optional viewport matrix: when set, every test in the scenario is
151 /// expanded into one variant per viewport (e.g. mobile/tablet/desktop).
152 /// Each variant overrides the test's viewport and gets a ` — <name>`
153 /// suffix on the test name. Per-test budgets apply per variant.
154 #[serde(default)]
155 pub viewport_matrix: Option<ViewportMatrix>,
156 /// Class-name prefixes the `layout_no_issues` scan skips: elements
157 /// whose class matches any prefix are ignored by the fixed-element,
158 /// text-clipped, and overlap checks. Defaults cover the Angular CDK
159 /// screen-reader helpers (`cdk-visually-hidden`,
160 /// `cdk-describedby-message-container`, `cdk-overlay-container`),
161 /// which are intentionally 1x1 / off-screen.
162 #[serde(default = "default_layout_ignore_classes")]
163 pub layout_ignore_classes: Vec<String>,
164}
165
166/// A list of named viewports a scenario is expanded across.
167#[derive(Debug, Deserialize, Clone, Default)]
168pub struct ViewportMatrix {
169 /// The viewport variants (`{name, width, height}`).
170 #[serde(default)]
171 pub viewports: Vec<ViewportDef>,
172}
173
174/// One named viewport size in a matrix.
175#[derive(Debug, Deserialize, Clone)]
176pub struct ViewportDef {
177 /// Human-readable variant name (appended to test names, e.g.
178 /// `— mobile`).
179 pub name: String,
180 /// Browser viewport width in pixels.
181 pub width: u32,
182 /// Browser viewport height in pixels.
183 pub height: u32,
184}
185
186/// A named endpoint definition with pricing.
187#[derive(Debug, Deserialize, Clone, Default)]
188pub struct EndpointConfig {
189 /// Endpoint type: `llm`, `mcp`, or `a2a`.
190 #[serde(rename = "type")]
191 pub endpoint_type: EndpointType,
192 /// Base URL for the endpoint.
193 #[serde(default)]
194 pub url: Option<String>,
195 /// Model name (LLM endpoints only).
196 #[serde(default)]
197 pub model: Option<String>,
198 /// API key / bearer token.
199 #[serde(default)]
200 pub api_key: Option<String>,
201 /// Custom HTTP headers as JSON key-value pairs.
202 #[serde(default, deserialize_with = "deserialize_headers")]
203 pub headers: HashMap<String, String>,
204 /// Pricing configuration.
205 #[serde(default)]
206 pub pricing: Option<PricingConfig>,
207 /// Task types this endpoint serves by default
208 /// (e.g. `["targeting", "assertion"]`).
209 #[serde(default)]
210 pub default_for: Vec<String>,
211 /// Command to launch an MCP server subprocess (stdio transport).
212 #[serde(default)]
213 pub command: Option<String>,
214 /// Arguments for the MCP server command.
215 #[serde(default)]
216 pub args: Vec<String>,
217 /// Whether this LLM endpoint accepts image parts (vision) in addition
218 /// to text. `assert` steps with `screenshot = true` require a vision
219 /// endpoint.
220 #[serde(default)]
221 pub vision: bool,
222 /// How often a single chat completion against this endpoint is retried
223 /// on transient failures (HTTP 429/5xx, empty 200 bodies, network
224 /// errors) before the fallback chain is tried. Default: 3 (override
225 /// globally with `HARNESS_LLM_CALL_ATTEMPTS`).
226 #[serde(default)]
227 pub max_attempts: Option<u32>,
228 /// Ordered names of other endpoints to try when this endpoint exhausts
229 /// its attempts. Only LLM endpoints are eligible. Useful for pairing a
230 /// cheap primary model with a more powerful/expensive fallback.
231 #[serde(default)]
232 pub fallbacks: Vec<String>,
233}
234
235/// Type discriminator for endpoint configuration.
236#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Default)]
237#[serde(rename_all = "lowercase")]
238pub enum EndpointType {
239 /// OpenAI-compatible LLM API.
240 #[default]
241 Llm,
242 /// Model Context Protocol server.
243 Mcp,
244 /// Agent-to-Agent protocol agent.
245 A2a,
246}
247
248/// Pricing configuration for an endpoint.
249#[derive(Debug, Deserialize, Clone, Default)]
250pub struct PricingConfig {
251 /// Cost per 1M input tokens (USD).
252 #[serde(default)]
253 pub input_per_1m_tokens: f64,
254 /// Cost per 1M output tokens (USD).
255 #[serde(default)]
256 pub output_per_1m_tokens: f64,
257 /// Flat cost per call (USD), used for MCP/agent endpoints.
258 #[serde(default)]
259 pub per_call: f64,
260}
261
262/// Budget limits for test execution.
263#[derive(Debug, Deserialize, Clone, Default)]
264pub struct BudgetsConfig {
265 /// Global budget across all tests in the scenario.
266 #[serde(default)]
267 pub global: Option<BudgetDef>,
268 /// Default per-test budget. Individual tests can override.
269 #[serde(default)]
270 pub per_test_default: Option<BudgetDef>,
271}
272
273/// A budget definition with limits and enforcement mode.
274#[derive(Debug, Deserialize, Clone)]
275pub struct BudgetDef {
276 /// Maximum cost in USD.
277 #[serde(default)]
278 pub max_cost: Option<f64>,
279 /// Maximum total tokens (input + output).
280 #[serde(default)]
281 pub max_tokens: Option<u64>,
282 /// Maximum number of calls (LLM, MCP, agent combined).
283 #[serde(default)]
284 pub max_calls: Option<u64>,
285 /// Enforcement mode: `hard` (abort) or `soft` (warn and continue).
286 #[serde(default)]
287 pub enforcement: Option<BudgetEnforcement>,
288}
289
290/// Budget enforcement strategy.
291#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
292#[serde(rename_all = "lowercase")]
293pub enum BudgetEnforcement {
294 /// Abort the test or run when budget is exceeded.
295 Hard,
296 /// Log a warning but continue execution.
297 Soft,
298}
299
300/// MCP server exposure configuration.
301#[derive(Debug, Deserialize, Clone)]
302pub struct McpServerConfig {
303 /// Whether to enable the embedded MCP server.
304 #[serde(default)]
305 pub enabled: bool,
306 /// Port to listen on.
307 #[serde(default = "default_mcp_port")]
308 pub port: u16,
309}
310
311const fn default_mcp_port() -> u16 {
312 3000
313}
314
315/// A2A agent server exposure configuration.
316#[derive(Debug, Deserialize, Clone)]
317pub struct A2aServerConfig {
318 /// Whether to enable the embedded A2A agent server.
319 #[serde(default)]
320 pub enabled: bool,
321 /// Port to listen on.
322 #[serde(default = "default_a2a_port")]
323 pub port: u16,
324}
325
326const fn default_a2a_port() -> u16 {
327 3100
328}
329
330fn deserialize_headers<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
331where
332 D: serde::Deserializer<'de>,
333{
334 let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
335 let Some(json) = raw else {
336 return Ok(HashMap::new());
337 };
338 let serde_json::Value::Object(obj) = json else {
339 return Ok(HashMap::new());
340 };
341 Ok(obj
342 .into_iter()
343 .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_owned())))
344 .collect())
345}
346
347const fn default_auto_navigate() -> bool {
348 true
349}
350
351fn default_layout_ignore_classes() -> Vec<String> {
352 vec![
353 "cdk-visually-hidden".to_owned(),
354 "cdk-describedby-message-container".to_owned(),
355 "cdk-overlay-container".to_owned(),
356 ]
357}
358
359const fn default_temperature() -> f64 {
360 0.0
361}
362
363fn deserialize_model_params<'de, D>(deserializer: D) -> Result<HashMap<String, Value>, D::Error>
364where
365 D: serde::Deserializer<'de>,
366{
367 #[derive(Deserialize)]
368 #[serde(untagged)]
369 enum Raw {
370 Map(HashMap<String, Value>),
371 Table(HashMap<String, Value>),
372 }
373 let raw: Option<Raw> = Option::deserialize(deserializer)?;
374 Ok(match raw {
375 Some(Raw::Map(m) | Raw::Table(m)) => m,
376 None => HashMap::new(),
377 })
378}
379
380/// Reusable assertion definition referenced by name from `assert` steps.
381///
382/// Definitions can either reference a built-in preset via `preset`, supply a
383/// custom LLM `prompt`, or define a **custom preset** by providing both
384/// `system` and `user_template`. Custom presets support the same template
385/// variables as built-in presets: `{url}`, `{title}`, `{content}`,
386/// `{expected_text}`, `{description}`.
387#[derive(Debug, Deserialize, Clone)]
388pub struct AssertDefinition {
389 /// Unique name used to reference this definition from steps.
390 pub name: String,
391 /// Predefined assertion preset name
392 /// (e.g. `no_error_on_page`, `text_visible`).
393 #[serde(default)]
394 pub preset: Option<String>,
395 /// Custom LLM prompt for assertion evaluation.
396 #[serde(default)]
397 pub prompt: Option<String>,
398 /// System prompt for a custom preset.
399 #[serde(default)]
400 pub system: Option<String>,
401 /// User template (with `{placeholders}`) for a custom preset.
402 #[serde(default)]
403 pub user_template: Option<String>,
404 /// Text that the `text_visible` preset checks for, or the
405 /// `{expected_text}` placeholder value for custom presets.
406 #[serde(default)]
407 pub assert_text: Option<String>,
408 /// Agent endpoint to call for this assertion.
409 #[serde(default)]
410 pub agent: Option<String>,
411 /// Agent task template for this assertion.
412 #[serde(default)]
413 pub task_template: Option<String>,
414}
415
416/// A group of steps that form a single test scenario.
417#[derive(Debug, Deserialize, Clone)]
418pub struct TestGroup {
419 /// Human-readable test name.
420 pub name: String,
421 /// Override the global `start_url` for this test.
422 #[serde(default)]
423 pub start_url: Option<String>,
424 /// Override the global `auto_navigate` for this test.
425 #[serde(default)]
426 pub auto_navigate: Option<bool>,
427 /// Override the global `base_url` for this test.
428 #[serde(default)]
429 pub base_url: Option<String>,
430 /// Override the global `timeout_secs` for this test.
431 #[serde(default)]
432 pub timeout_secs: Option<u64>,
433 /// Override the global `browser_headless` for this test.
434 #[serde(default)]
435 pub browser_headless: Option<bool>,
436 /// Override the global viewport width for this test (applied via CDP
437 /// `Emulation.setDeviceMetricsOverride` before the test runs).
438 #[serde(default)]
439 pub viewport_width: Option<u32>,
440 /// Override the global viewport height for this test.
441 #[serde(default)]
442 pub viewport_height: Option<u32>,
443 /// Per-test budget override.
444 #[serde(default)]
445 pub budget: Option<BudgetDef>,
446 /// Endpoint to use for all steps in this test (can be overridden
447 /// per-step).
448 #[serde(default)]
449 pub endpoint: Option<String>,
450 /// Ordered steps to execute.
451 #[serde(default)]
452 pub steps: Vec<TestStep>,
453}
454
455/// A single step in a test. The `kind` field determines which variant is
456/// deserialized and which field constraints apply.
457#[derive(Debug, Deserialize, Clone)]
458#[serde(tag = "kind")]
459pub enum TestStep {
460 /// Navigate the browser to a URL.
461 #[serde(rename = "navigate")]
462 Navigate {
463 /// URL to navigate to (absolute, or relative to the test's
464 /// base URL).
465 url: String,
466 /// Milliseconds to wait after navigation completes.
467 #[serde(default)]
468 wait_after_ms: Option<u64>,
469 },
470
471 /// Click an element described in natural language.
472 #[serde(rename = "click")]
473 Click {
474 /// Natural language description of the element. The LLM resolves
475 /// this to a CSS selector at runtime.
476 target: String,
477 /// Explicit CSS selector override (bypasses LLM resolution).
478 #[serde(default)]
479 selector: Option<String>,
480 /// Milliseconds to wait after the click.
481 #[serde(default)]
482 wait_after_ms: Option<u64>,
483 /// Endpoint to use for LLM element targeting.
484 #[serde(default)]
485 endpoint: Option<String>,
486 /// Idempotent: when the target element is absent the step is
487 /// reported skipped instead of failed (the action was already
488 /// done / not applicable).
489 #[serde(default)]
490 idempotent: bool,
491 },
492
493 /// Type text into an input element.
494 #[serde(rename = "type")]
495 Type {
496 /// Natural language description of the target input element.
497 target: String,
498 /// Text to type into the element.
499 text: String,
500 /// Explicit CSS selector override (bypasses LLM resolution).
501 #[serde(default)]
502 selector: Option<String>,
503 /// Milliseconds to wait after typing.
504 #[serde(default)]
505 wait_after_ms: Option<u64>,
506 /// Endpoint to use for LLM element targeting.
507 #[serde(default)]
508 endpoint: Option<String>,
509 /// Idempotent: when the target element is absent the step is
510 /// reported skipped instead of failed (the action was already
511 /// done / not applicable).
512 #[serde(default)]
513 idempotent: bool,
514 },
515
516 /// Wait for an element to appear on the page.
517 #[serde(rename = "wait")]
518 Wait {
519 /// Natural language description of the element to wait for.
520 target: String,
521 /// Explicit CSS selector override (bypasses LLM resolution).
522 #[serde(default)]
523 selector: Option<String>,
524 /// Wait until the page's visible text contains this substring
525 /// (alternative to `selector`; either or both may be set — both are
526 /// required to hold when both are set).
527 #[serde(default)]
528 text: Option<String>,
529 /// Maximum milliseconds to wait (default: 10000).
530 #[serde(default)]
531 timeout_ms: Option<u64>,
532 /// Endpoint to use for LLM element targeting.
533 #[serde(default)]
534 endpoint: Option<String>,
535 /// Idempotent: when the condition never becomes true within the
536 /// timeout the step is reported skipped instead of failed (the
537 /// condition was not applicable, e.g. already-authenticated
538 /// pages in a viewport matrix).
539 #[serde(default)]
540 idempotent: bool,
541 },
542
543 /// Evaluate an assertion against the current page content.
544 #[serde(rename = "assert")]
545 Assert {
546 /// Reference to a named `[[definitions]]` entry.
547 #[serde(default)]
548 definition: Option<String>,
549 /// Inline predefined assertion preset (e.g. `no_error_on_page`).
550 #[serde(default)]
551 preset: Option<String>,
552 /// Inline custom LLM prompt for assertion evaluation.
553 #[serde(default)]
554 prompt: Option<String>,
555 /// Text that the `text_visible` preset checks for.
556 #[serde(default)]
557 assert_text: Option<String>,
558 /// Endpoint to use for this assertion's LLM call.
559 #[serde(default)]
560 endpoint: Option<String>,
561 /// Attach a screenshot of the current viewport to the assertion so
562 /// the LLM can evaluate visuals (overlaps, clipping, layout).
563 /// Requires the resolved endpoint to declare `vision = true`.
564 #[serde(default)]
565 screenshot: bool,
566 },
567
568 /// Take a screenshot of the current page.
569 #[serde(rename = "screenshot")]
570 Screenshot {
571 /// File path to save the screenshot (default: `screenshot.png`).
572 #[serde(default)]
573 path: Option<String>,
574 },
575
576 /// Call an A2A agent with a task.
577 #[serde(rename = "agent")]
578 Agent {
579 /// Name of the agent endpoint to call.
580 agent: String,
581 /// Task description / prompt for the agent.
582 task: String,
583 /// Optional definition name with a task template.
584 #[serde(default)]
585 definition: Option<String>,
586 },
587
588 /// Call an MCP server tool.
589 #[serde(rename = "mcp")]
590 Mcp {
591 /// Name of the MCP server endpoint.
592 server: String,
593 /// Tool name to invoke on the server.
594 tool: String,
595 /// Tool arguments as JSON.
596 #[serde(default)]
597 args: Option<serde_json::Value>,
598 },
599}