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//! | `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    /// LLM provider protocol: `openai` (default, OpenAI-compatible chat
234    /// completions), `azure` (`Azure` `OpenAI`), or `bedrock` (AWS Bedrock
235    /// Converse API; requires the `aws` cargo feature).
236    #[serde(default)]
237    pub provider: Provider,
238    /// `Azure` `OpenAI` deployment name (`provider = "azure"`). Defaults to
239    /// `model` when unset.
240    #[serde(default)]
241    pub deployment: Option<String>,
242    /// `Azure` `OpenAI` API version (`provider = "azure"`). Defaults to
243    /// `2024-10-21`.
244    #[serde(default)]
245    pub api_version: Option<String>,
246    /// Authentication configuration for LLM endpoints (API key, token
247    /// command, Entra ID client credentials / managed identity).
248    #[serde(default)]
249    pub auth: AuthConfig,
250    /// Extra HTTP headers produced by running a command per call, keyed by
251    /// header name. The command's stdout (first line) becomes the header
252    /// value. Provider-agnostic — applies to every LLM provider.
253    #[serde(default, deserialize_with = "deserialize_headers")]
254    pub header_commands: HashMap<String, String>,
255    /// AWS credential settings (`provider = "bedrock"`).
256    #[serde(default)]
257    pub aws: AwsConfig,
258}
259
260/// Type discriminator for endpoint configuration.
261#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Default)]
262#[serde(rename_all = "lowercase")]
263pub enum EndpointType {
264    /// OpenAI-compatible LLM API.
265    #[default]
266    Llm,
267    /// Model Context Protocol server.
268    Mcp,
269    /// Agent-to-Agent protocol agent.
270    A2a,
271}
272
273/// LLM provider protocol used by an LLM endpoint.
274#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
275#[serde(rename_all = "lowercase")]
276pub enum Provider {
277    /// OpenAI-compatible Chat Completions API
278    /// (`POST <url>/v1/chat/completions`).
279    #[default]
280    Openai,
281    /// `Azure` `OpenAI`
282    /// (`POST <url>/openai/deployments/<deployment>/chat/completions`).
283    Azure,
284    /// AWS Bedrock Converse API (`POST https://bedrock-runtime.<region>
285    /// .amazonaws.com/model/<model>/converse`). Requires the `aws` cargo
286    /// feature; uses the standard AWS credential chain unless overridden.
287    Bedrock,
288}
289
290/// Authentication mode for an LLM endpoint.
291#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
292#[serde(rename_all = "kebab-case")]
293pub enum AuthMode {
294    /// Static API key. Sent as `Authorization: Bearer <key>` by default;
295    /// set [`AuthConfig::api_key_header`] (or use the `azure` provider)
296    /// to send it in a different header.
297    #[default]
298    ApiKey,
299    /// Execute a command and use its stdout (first line) as the bearer
300    /// token. Uses the `token_command` field. Provider-agnostic escape
301    /// hatch — e.g. `az account get-access-token …`.
302    TokenCommand,
303    /// Entra ID (`Azure` AD) OAuth 2.0 client-credentials grant: exchanges
304    /// `client_id` + `client_secret` in `tenant_id` for a bearer token.
305    EntraClientCredentials,
306    /// Entra ID managed identity: fetches a bearer token from the IMDS
307    /// endpoint (works on `Azure` VMs / App Service / ACI with a
308    /// system-assigned identity; zero credentials in the config).
309    EntraManagedIdentity,
310}
311
312/// Authentication settings for an LLM endpoint.
313#[derive(Debug, Deserialize, Clone, Default)]
314pub struct AuthConfig {
315    /// Which authentication mode to use. Default: `api-key`.
316    #[serde(default)]
317    pub mode: AuthMode,
318    /// Header name that receives the API key instead of
319    /// `Authorization: Bearer` (`mode = "api-key"`). For example the `Azure`
320    /// `OpenAI` `api-key` header.
321    #[serde(default)]
322    pub api_key_header: Option<String>,
323    /// Command whose stdout (first line) is used as the bearer token
324    /// (`mode = "token-command"`).
325    #[serde(default)]
326    pub token_command: Option<String>,
327    /// Entra tenant id (`mode = "entra-client-credentials"`,
328    /// `"entra-managed-identity"`).
329    #[serde(default)]
330    pub tenant_id: Option<String>,
331    /// Entra client id (`mode = "entra-client-credentials"`).
332    #[serde(default)]
333    pub client_id: Option<String>,
334    /// Entra client secret (`mode = "entra-client-credentials"`).
335    #[serde(default)]
336    pub client_secret: Option<String>,
337    /// Entra token scope. Defaults to
338    /// `https://cognitiveservices.azure.com/.default`.
339    #[serde(default)]
340    pub scope: Option<String>,
341    /// Override for the Entra token endpoint
342    /// (`mode = "entra-client-credentials"`), default
343    /// `https://login.microsoftonline.com`. Also used as the IMDS identity
344    /// endpoint base for `"entra-managed-identity"` (default
345    /// `http://169.254.169.254`). Mainly useful for tests and proxies.
346    #[serde(default)]
347    pub token_url: Option<String>,
348    /// Seconds to reuse a fetched (non-API-key) token before refetching.
349    /// For Entra modes the server-issued expiry is used when available;
350    /// this caps the reuse window. Default 300.
351    #[serde(default)]
352    pub cache_ttl_secs: Option<u64>,
353}
354
355/// AWS credential and region settings for a `bedrock` provider endpoint.
356///
357/// When no explicit access key is given, the standard AWS credential chain
358/// is used (env vars, shared config `~/.aws/config` + `~/.aws/credentials`,
359/// SSO, ECS/IMDS) — the same behavior as the AWS CLI. `profile` selects a
360/// named profile from the shared config, and `region` overrides the chain
361/// default.
362#[derive(Debug, Deserialize, Clone, Default)]
363pub struct AwsConfig {
364    /// Named profile from `~/.aws/config` / `~/.aws/credentials` to use
365    /// (default: the AWS CLI default selection via `AWS_PROFILE` or
366    /// `default`).
367    #[serde(default)]
368    pub profile: Option<String>,
369    /// AWS region (default: `AWS_REGION` env or the profile's region;
370    /// required if neither is set).
371    #[serde(default)]
372    pub region: Option<String>,
373    /// Explicit access key id (bypasses the credential chain).
374    #[serde(default)]
375    pub access_key_id: Option<String>,
376    /// Explicit secret access key (with `access_key_id`).
377    #[serde(default)]
378    pub secret_access_key: Option<String>,
379    /// Optional session token for explicit temporary credentials.
380    #[serde(default)]
381    pub session_token: Option<String>,
382}
383
384/// Pricing configuration for an endpoint.
385#[derive(Debug, Deserialize, Clone, Default)]
386pub struct PricingConfig {
387    /// Cost per 1M input tokens (USD).
388    #[serde(default)]
389    pub input_per_1m_tokens: f64,
390    /// Cost per 1M output tokens (USD).
391    #[serde(default)]
392    pub output_per_1m_tokens: f64,
393    /// Flat cost per call (USD), used for MCP/agent endpoints.
394    #[serde(default)]
395    pub per_call: f64,
396}
397
398/// Budget limits for test execution.
399#[derive(Debug, Deserialize, Clone, Default)]
400pub struct BudgetsConfig {
401    /// Global budget across all tests in the scenario.
402    #[serde(default)]
403    pub global: Option<BudgetDef>,
404    /// Default per-test budget. Individual tests can override.
405    #[serde(default)]
406    pub per_test_default: Option<BudgetDef>,
407}
408
409/// A budget definition with limits and enforcement mode.
410#[derive(Debug, Deserialize, Clone)]
411pub struct BudgetDef {
412    /// Maximum cost in USD.
413    #[serde(default)]
414    pub max_cost: Option<f64>,
415    /// Maximum total tokens (input + output).
416    #[serde(default)]
417    pub max_tokens: Option<u64>,
418    /// Maximum number of calls (LLM, MCP, agent combined).
419    #[serde(default)]
420    pub max_calls: Option<u64>,
421    /// Enforcement mode: `hard` (abort) or `soft` (warn and continue).
422    #[serde(default)]
423    pub enforcement: Option<BudgetEnforcement>,
424}
425
426/// Budget enforcement strategy.
427#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
428#[serde(rename_all = "lowercase")]
429pub enum BudgetEnforcement {
430    /// Abort the test or run when budget is exceeded.
431    Hard,
432    /// Log a warning but continue execution.
433    Soft,
434}
435
436/// MCP server exposure configuration.
437#[derive(Debug, Deserialize, Clone)]
438pub struct McpServerConfig {
439    /// Whether to enable the embedded MCP server.
440    #[serde(default)]
441    pub enabled: bool,
442    /// Port to listen on.
443    #[serde(default = "default_mcp_port")]
444    pub port: u16,
445}
446
447const fn default_mcp_port() -> u16 {
448    3000
449}
450
451/// A2A agent server exposure configuration.
452#[derive(Debug, Deserialize, Clone)]
453pub struct A2aServerConfig {
454    /// Whether to enable the embedded A2A agent server.
455    #[serde(default)]
456    pub enabled: bool,
457    /// Port to listen on.
458    #[serde(default = "default_a2a_port")]
459    pub port: u16,
460}
461
462const fn default_a2a_port() -> u16 {
463    3100
464}
465
466fn deserialize_headers<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
467where
468    D: serde::Deserializer<'de>,
469{
470    let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
471    let Some(json) = raw else {
472        return Ok(HashMap::new());
473    };
474    let serde_json::Value::Object(obj) = json else {
475        return Ok(HashMap::new());
476    };
477    Ok(obj
478        .into_iter()
479        .filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_owned())))
480        .collect())
481}
482
483const fn default_auto_navigate() -> bool {
484    true
485}
486
487fn default_layout_ignore_classes() -> Vec<String> {
488    vec![
489        "cdk-visually-hidden".to_owned(),
490        "cdk-describedby-message-container".to_owned(),
491        "cdk-overlay-container".to_owned(),
492    ]
493}
494
495const fn default_temperature() -> f64 {
496    0.0
497}
498
499fn deserialize_model_params<'de, D>(deserializer: D) -> Result<HashMap<String, Value>, D::Error>
500where
501    D: serde::Deserializer<'de>,
502{
503    #[derive(Deserialize)]
504    #[serde(untagged)]
505    enum Raw {
506        Map(HashMap<String, Value>),
507        Table(HashMap<String, Value>),
508    }
509    let raw: Option<Raw> = Option::deserialize(deserializer)?;
510    Ok(match raw {
511        Some(Raw::Map(m) | Raw::Table(m)) => m,
512        None => HashMap::new(),
513    })
514}
515
516/// Reusable assertion definition referenced by name from `assert` steps.
517///
518/// Definitions can either reference a built-in preset via `preset`, supply a
519/// custom LLM `prompt`, or define a **custom preset** by providing both
520/// `system` and `user_template`. Custom presets support the same template
521/// variables as built-in presets: `{url}`, `{title}`, `{content}`,
522/// `{expected_text}`, `{description}`.
523#[derive(Debug, Deserialize, Clone)]
524pub struct AssertDefinition {
525    /// Unique name used to reference this definition from steps.
526    pub name: String,
527    /// Predefined assertion preset name
528    /// (e.g. `no_error_on_page`, `text_visible`).
529    #[serde(default)]
530    pub preset: Option<String>,
531    /// Custom LLM prompt for assertion evaluation.
532    #[serde(default)]
533    pub prompt: Option<String>,
534    /// System prompt for a custom preset.
535    #[serde(default)]
536    pub system: Option<String>,
537    /// User template (with `{placeholders}`) for a custom preset.
538    #[serde(default)]
539    pub user_template: Option<String>,
540    /// Text that the `text_visible` preset checks for, or the
541    /// `{expected_text}` placeholder value for custom presets.
542    #[serde(default)]
543    pub assert_text: Option<String>,
544    /// Agent endpoint to call for this assertion.
545    #[serde(default)]
546    pub agent: Option<String>,
547    /// Agent task template for this assertion.
548    #[serde(default)]
549    pub task_template: Option<String>,
550}
551
552/// A group of steps that form a single test scenario.
553#[derive(Debug, Deserialize, Clone)]
554pub struct TestGroup {
555    /// Human-readable test name.
556    pub name: String,
557    /// Override the global `start_url` for this test.
558    #[serde(default)]
559    pub start_url: Option<String>,
560    /// Override the global `auto_navigate` for this test.
561    #[serde(default)]
562    pub auto_navigate: Option<bool>,
563    /// Override the global `base_url` for this test.
564    #[serde(default)]
565    pub base_url: Option<String>,
566    /// Override the global `timeout_secs` for this test.
567    #[serde(default)]
568    pub timeout_secs: Option<u64>,
569    /// Override the global `browser_headless` for this test.
570    #[serde(default)]
571    pub browser_headless: Option<bool>,
572    /// Override the global viewport width for this test (applied via CDP
573    /// `Emulation.setDeviceMetricsOverride` before the test runs).
574    #[serde(default)]
575    pub viewport_width: Option<u32>,
576    /// Override the global viewport height for this test.
577    #[serde(default)]
578    pub viewport_height: Option<u32>,
579    /// Per-test budget override.
580    #[serde(default)]
581    pub budget: Option<BudgetDef>,
582    /// Endpoint to use for all steps in this test (can be overridden
583    /// per-step).
584    #[serde(default)]
585    pub endpoint: Option<String>,
586    /// Ordered steps to execute.
587    #[serde(default)]
588    pub steps: Vec<TestStep>,
589}
590
591/// A single step in a test. The `kind` field determines which variant is
592/// deserialized and which field constraints apply.
593#[derive(Debug, Deserialize, Clone)]
594#[serde(tag = "kind")]
595pub enum TestStep {
596    /// Navigate the browser to a URL.
597    #[serde(rename = "navigate")]
598    Navigate {
599        /// URL to navigate to (absolute, or relative to the test's
600        /// base URL).
601        url: String,
602        /// Milliseconds to wait after navigation completes.
603        #[serde(default)]
604        wait_after_ms: Option<u64>,
605    },
606
607    /// Click an element described in natural language.
608    #[serde(rename = "click")]
609    Click {
610        /// Natural language description of the element. The LLM resolves
611        /// this to a CSS selector at runtime.
612        target: String,
613        /// Explicit CSS selector override (bypasses LLM resolution).
614        #[serde(default)]
615        selector: Option<String>,
616        /// Milliseconds to wait after the click.
617        #[serde(default)]
618        wait_after_ms: Option<u64>,
619        /// Endpoint to use for LLM element targeting.
620        #[serde(default)]
621        endpoint: Option<String>,
622        /// Idempotent: when the target element is absent the step is
623        /// reported skipped instead of failed (the action was already
624        /// done / not applicable).
625        #[serde(default)]
626        idempotent: bool,
627    },
628
629    /// Type text into an input element.
630    #[serde(rename = "type")]
631    Type {
632        /// Natural language description of the target input element.
633        target: String,
634        /// Text to type into the element.
635        text: String,
636        /// Explicit CSS selector override (bypasses LLM resolution).
637        #[serde(default)]
638        selector: Option<String>,
639        /// Milliseconds to wait after typing.
640        #[serde(default)]
641        wait_after_ms: Option<u64>,
642        /// Endpoint to use for LLM element targeting.
643        #[serde(default)]
644        endpoint: Option<String>,
645        /// Idempotent: when the target element is absent the step is
646        /// reported skipped instead of failed (the action was already
647        /// done / not applicable).
648        #[serde(default)]
649        idempotent: bool,
650    },
651
652    /// Wait for an element to appear on the page.
653    #[serde(rename = "wait")]
654    Wait {
655        /// Natural language description of the element to wait for.
656        target: String,
657        /// Explicit CSS selector override (bypasses LLM resolution).
658        #[serde(default)]
659        selector: Option<String>,
660        /// Wait until the page's visible text contains this substring
661        /// (alternative to `selector`; either or both may be set — both are
662        /// required to hold when both are set).
663        #[serde(default)]
664        text: Option<String>,
665        /// Maximum milliseconds to wait (default: 10000).
666        #[serde(default)]
667        timeout_ms: Option<u64>,
668        /// Endpoint to use for LLM element targeting.
669        #[serde(default)]
670        endpoint: Option<String>,
671        /// Idempotent: when the condition never becomes true within the
672        /// timeout the step is reported skipped instead of failed (the
673        /// condition was not applicable, e.g. already-authenticated
674        /// pages in a viewport matrix).
675        #[serde(default)]
676        idempotent: bool,
677    },
678
679    /// Evaluate an assertion against the current page content.
680    #[serde(rename = "assert")]
681    Assert {
682        /// Reference to a named `[[definitions]]` entry.
683        #[serde(default)]
684        definition: Option<String>,
685        /// Inline predefined assertion preset (e.g. `no_error_on_page`).
686        #[serde(default)]
687        preset: Option<String>,
688        /// Inline custom LLM prompt for assertion evaluation.
689        #[serde(default)]
690        prompt: Option<String>,
691        /// Text that the `text_visible` preset checks for.
692        #[serde(default)]
693        assert_text: Option<String>,
694        /// Endpoint to use for this assertion's LLM call.
695        #[serde(default)]
696        endpoint: Option<String>,
697        /// Attach a screenshot of the current viewport to the assertion so
698        /// the LLM can evaluate visuals (overlaps, clipping, layout).
699        /// Requires the resolved endpoint to declare `vision = true`.
700        #[serde(default)]
701        screenshot: bool,
702    },
703
704    /// Take a screenshot of the current page.
705    #[serde(rename = "screenshot")]
706    Screenshot {
707        /// File path to save the screenshot (default: `screenshot.png`).
708        #[serde(default)]
709        path: Option<String>,
710    },
711
712    /// Call an A2A agent with a task.
713    #[serde(rename = "agent")]
714    Agent {
715        /// Name of the agent endpoint to call.
716        agent: String,
717        /// Task description / prompt for the agent.
718        task: String,
719        /// Optional definition name with a task template.
720        #[serde(default)]
721        definition: Option<String>,
722    },
723
724    /// Call an MCP server tool.
725    #[serde(rename = "mcp")]
726    Mcp {
727        /// Name of the MCP server endpoint.
728        server: String,
729        /// Tool name to invoke on the server.
730        tool: String,
731        /// Tool arguments as JSON.
732        #[serde(default)]
733        args: Option<serde_json::Value>,
734    },
735}