Skip to main content

llm_browser_testkit/
runner.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use anyhow::Context;
6use headless_chrome::{Browser, LaunchOptions, Tab};
7
8use crate::a2a::A2aClient;
9use crate::budgets::{BudgetStatus, BudgetTracker};
10use crate::costs::UsageTracker;
11use crate::endpoints::{EndpointRegistry, TaskType};
12use crate::llm_chat_with_usage;
13use crate::mcp_client::McpClient;
14use crate::scenario::{AssertDefinition, ScenarioConfig, TestGroup, TestStep};
15use crate::truncate;
16use crate::LlmConfig;
17use crate::DOM_EXTRACT_JS;
18
19/// Executes a [`Scenario`] against a real browser with optional LLM
20/// assistance for element targeting and assertions.
21pub struct ScenarioRunner {
22    config: ScenarioConfig,
23    definitions: HashMap<String, AssertDefinition>,
24    llm: LlmConfig,
25    timeout: Duration,
26    viewport_width: u32,
27    viewport_height: u32,
28    endpoints: EndpointRegistry,
29    usage: Arc<UsageTracker>,
30    budgets: BudgetTracker,
31}
32
33/// Aggregated results from a scenario run.
34#[derive(Debug, Default)]
35pub struct RunReport {
36    /// Number of tests that passed.
37    pub tests_passed: u32,
38    /// Number of tests that failed.
39    pub tests_failed: u32,
40    /// Number of steps that passed.
41    pub passed: u32,
42    /// Number of steps that failed.
43    pub failed: u32,
44    /// Number of steps that were skipped.
45    pub skipped: u32,
46    /// Per-step details.
47    pub details: Vec<StepResult>,
48}
49
50/// Result of a single step execution.
51#[derive(Debug)]
52pub struct StepResult {
53    /// The step name.
54    pub name: String,
55    /// Whether the step passed, failed, or was skipped.
56    pub status: StepStatus,
57    /// Human-readable result message.
58    pub message: String,
59}
60
61/// Outcome for a single step.
62#[derive(Debug, PartialEq, Eq)]
63pub enum StepStatus {
64    /// Step executed successfully and all assertions passed.
65    Passed,
66    /// Step execution or assertion failed.
67    Failed,
68    /// Step was skipped.
69    Skipped,
70}
71
72/// Predefined assertion preset definition.
73struct AssertPreset {
74    name: &'static str,
75    system: &'static str,
76    user_template: &'static str,
77}
78
79/// Built-in assertion presets.
80#[allow(clippy::literal_string_with_formatting_args)]
81const ASSERTION_PRESETS: &[AssertPreset] = &[
82    AssertPreset {
83        name: "no_error_on_page",
84        system: "You are a QA tester. Evaluate if a web page contains error messages, stack traces, exception text, HTTP error codes, 'undefined' errors, or any indication of a malfunction. Be strict — even minor rendering glitches count as errors.",
85        user_template: "Check if the following page content contains ANY errors or malfunctions:\n\nURL: {url}\nTitle: {title}\n\nPage Content:\n{content}\n\nRespond with exactly \"PASS\" if there are NO errors, or \"FAIL: <reason>\" if there are errors. Only respond with PASS or FAIL.",
86    },
87    AssertPreset {
88        name: "text_visible",
89        system: "You are a QA tester. Your task is to check if specific text is visible in the page content.",
90        user_template: "Check if the following text appears in the page content:\n\nTEXT TO FIND: \"{expected_text}\"\n\nURL: {url}\n\nPage Content:\n{content}\n\nRespond with exactly \"PASS\" if the text is present (even partial match is OK), or \"FAIL: text not found\" if it is not.",
91    },
92    AssertPreset {
93        name: "element_exists",
94        system: "You are a QA tester. Check if a described UI element exists on a web page.",
95        user_template: "Check if the following element exists on the page:\n\nELEMENT: \"{description}\"\n\nURL: {url}\n\nPage Content:\n{content}\n\nRespond with exactly \"PASS\" if the element exists, or \"FAIL: <reason>\" if it does not.",
96    },
97];
98
99impl ScenarioRunner {
100    /// Creates a new runner with the given scenario configuration and
101    /// assertion definitions.
102    #[must_use]
103    pub fn new(scenario_config: ScenarioConfig, definitions: Vec<AssertDefinition>) -> Self {
104        let endpoints = EndpointRegistry::from_config(&scenario_config.endpoints);
105        let budgets = BudgetTracker::from_config(&scenario_config.budgets);
106
107        let llm = LlmConfig {
108            url: scenario_config
109                .llm_url
110                .clone()
111                .unwrap_or_else(crate::llm_base_url),
112            model: scenario_config
113                .llm_model
114                .clone()
115                .unwrap_or_else(crate::llm_model),
116            api_key: scenario_config
117                .llm_api_key
118                .clone()
119                .or_else(|| std::env::var("HARNESS_LLM_API_KEY").ok()),
120            headers: if scenario_config.llm_headers.is_empty() {
121                crate::parse_headers_env()
122            } else {
123                scenario_config.llm_headers.clone()
124            },
125            timeout: Duration::from_secs(scenario_config.timeout_secs.unwrap_or(60)),
126            temperature: scenario_config.temperature,
127            thinking: scenario_config.thinking,
128            model_params: scenario_config.model_params.clone(),
129        };
130        let defs_map: HashMap<String, AssertDefinition> = definitions
131            .into_iter()
132            .map(|d| (d.name.clone(), d))
133            .collect();
134
135        Self {
136            timeout: Duration::from_secs(scenario_config.timeout_secs.unwrap_or(60)),
137            viewport_width: scenario_config.viewport_width.unwrap_or(1280),
138            viewport_height: scenario_config.viewport_height.unwrap_or(720),
139            config: scenario_config,
140            definitions: defs_map,
141            llm,
142            endpoints,
143            usage: Arc::new(UsageTracker::new()),
144            budgets,
145        }
146    }
147
148    /// Returns a clone of the [`UsageTracker`] for reporting.
149    #[must_use]
150    pub fn usage_tracker(&self) -> Arc<UsageTracker> {
151        Arc::clone(&self.usage)
152    }
153
154    /// Returns a reference to the [`BudgetTracker`].
155    #[must_use]
156    pub const fn budget_tracker(&self) -> &BudgetTracker {
157        &self.budgets
158    }
159
160    /// Executes all test groups in the scenario and returns a report.
161    ///
162    /// # Errors
163    ///
164    /// Returns an error if the browser fails to launch.
165    #[allow(clippy::too_many_lines)]
166    pub fn run(&self, tests: &[TestGroup]) -> anyhow::Result<RunReport> {
167        let mut report = RunReport::default();
168
169        if tests.is_empty() {
170            eprintln!("No tests defined in scenario.");
171            return Ok(report);
172        }
173
174        let browser_headless = self.config.browser_headless.unwrap_or(true);
175
176        let launch_opts = LaunchOptions {
177            headless: browser_headless,
178            window_size: Some((self.viewport_width, self.viewport_height)),
179            sandbox: false,
180            ..LaunchOptions::default()
181        };
182
183        let browser = Browser::new(launch_opts).context("failed to launch browser")?;
184        let tab = browser.new_tab().context("failed to open browser tab")?;
185        let _ = tab.set_default_timeout(self.timeout);
186
187        // Start MCP server if configured
188        #[cfg(feature = "mcp-server")]
189        if let Some(ref mcp_cfg) = self.config.mcp_server {
190            if mcp_cfg.enabled {
191                let port = mcp_cfg.port;
192                std::thread::spawn(move || {
193                    let _ = crate::mcp_server::start_mcp_server(port);
194                });
195            }
196        }
197        #[cfg(not(feature = "mcp-server"))]
198        if let Some(mcp_cfg) = &self.config.mcp_server {
199            if mcp_cfg.enabled {
200                eprintln!("  ⚠️  MCP server configured but 'mcp-server' feature not enabled");
201            }
202        }
203
204        // Start A2A agent server if configured
205        #[cfg(feature = "a2a-server")]
206        if let Some(ref a2a_cfg) = self.config.a2a_server {
207            if a2a_cfg.enabled {
208                let port = a2a_cfg.port;
209                tokio::spawn(crate::a2a_server::start_a2a_server(port));
210            }
211        }
212        #[cfg(not(feature = "a2a-server"))]
213        if let Some(a2a_cfg) = &self.config.a2a_server {
214            if a2a_cfg.enabled {
215                eprintln!("  ⚠️  A2A server configured but 'a2a-server' feature not enabled");
216            }
217        }
218
219        for test in tests {
220            eprintln!("\n╔══════════════════════════════");
221            eprintln!("║  Test: {}", test.name);
222            eprintln!("╚══════════════════════════════");
223
224            self.usage.reset_per_test();
225
226            let test_result = self.run_test(test, &tab);
227            self.usage.commit_test(&test.name);
228
229            if test_result.failed == 0 && test_result.total > 0 {
230                report.tests_passed += 1;
231                eprintln!("  Test ✅ Passed");
232            } else if test_result.total > 0 {
233                report.tests_failed += 1;
234                eprintln!("  Test ❌ Failed");
235            }
236
237            report.passed += test_result.passed;
238            report.failed += test_result.failed;
239            report.skipped += test_result.skipped;
240            report.details.extend(test_result.details);
241        }
242
243        Ok(report)
244    }
245
246    #[allow(clippy::too_many_lines)]
247    fn run_test(&self, test: &TestGroup, tab: &Tab) -> TestRunResult {
248        let base_url = test
249            .base_url
250            .clone()
251            .or_else(|| self.config.base_url.clone())
252            .unwrap_or_else(crate::base_url);
253
254        let auto_navigate = test.auto_navigate.unwrap_or(self.config.auto_navigate);
255
256        let start_url = test
257            .start_url
258            .clone()
259            .or_else(|| self.config.start_url.clone())
260            .unwrap_or_else(|| "/dashboard".to_owned());
261
262        if auto_navigate {
263            let full_url = resolve_url(&start_url, &base_url);
264            eprintln!("  → auto-navigate: {full_url}");
265            let _ = tab.navigate_to(&full_url);
266            let _ = tab.wait_until_navigated();
267            std::thread::sleep(Duration::from_secs(4));
268        }
269
270        let mut result = TestRunResult::default();
271
272        for step in &test.steps {
273            result.total += 1;
274
275            let wait_ms = match step {
276                TestStep::Navigate { wait_after_ms, .. }
277                | TestStep::Click { wait_after_ms, .. }
278                | TestStep::Type { wait_after_ms, .. } => *wait_after_ms,
279                _ => None,
280            };
281
282            let step_result = match step {
283                TestStep::Navigate { url, .. } => {
284                    let full_url = resolve_url(url, &base_url);
285                    run_navigate_step(&full_url, tab)
286                }
287                TestStep::Click {
288                    target,
289                    selector,
290                    endpoint,
291                    ..
292                } => self.run_click(
293                    target,
294                    selector.as_deref(),
295                    endpoint.as_deref(),
296                    test.endpoint.as_deref(),
297                    tab,
298                ),
299                TestStep::Type {
300                    target,
301                    text,
302                    selector,
303                    endpoint,
304                    ..
305                } => self.run_type(
306                    target,
307                    text,
308                    selector.as_deref(),
309                    endpoint.as_deref(),
310                    test.endpoint.as_deref(),
311                    tab,
312                ),
313                TestStep::Wait {
314                    target,
315                    selector,
316                    timeout_ms,
317                    endpoint,
318                } => self.run_wait(
319                    target,
320                    selector.as_deref(),
321                    *timeout_ms,
322                    endpoint.as_deref(),
323                    test.endpoint.as_deref(),
324                    tab,
325                ),
326                TestStep::Assert {
327                    definition,
328                    preset,
329                    prompt,
330                    assert_text,
331                    endpoint,
332                } => self.run_assert(
333                    definition.as_deref(),
334                    preset.as_deref(),
335                    prompt.as_deref(),
336                    assert_text.as_deref(),
337                    endpoint.as_deref(),
338                    test.endpoint.as_deref(),
339                    tab,
340                ),
341                TestStep::Screenshot { path } => Self::run_screenshot(path.as_deref(), tab),
342                TestStep::Agent {
343                    agent,
344                    task,
345                    definition,
346                } => self.run_agent(agent, task, definition.as_deref(), test.endpoint.as_deref()),
347                TestStep::Mcp { server, tool, args } => self.run_mcp(server, tool, args.as_ref()),
348            };
349
350            eprintln!(
351                "    {} {} — {}",
352                if step_result.status == StepStatus::Passed {
353                    "✅"
354                } else if step_result.status == StepStatus::Failed {
355                    "❌"
356                } else {
357                    "⏭️"
358                },
359                step_result.name,
360                step_result.message,
361            );
362
363            match step_result.status {
364                StepStatus::Passed => result.passed += 1,
365                StepStatus::Failed => result.failed += 1,
366                StepStatus::Skipped => result.skipped += 1,
367            }
368
369            // Check per-test budget after each step
370            let test_usage = self.usage.current_test_snapshot();
371            let global_usage = self.usage.global_snapshot();
372            let budget_status = self.budgets.check_all(
373                &test.name,
374                &test_usage,
375                &global_usage,
376                test.budget.as_ref(),
377            );
378            match budget_status {
379                BudgetStatus::HardExceeded { message, .. } => {
380                    crate::reporting::print_budget_error(&message);
381                    result.details.push(StepResult {
382                        name: "[budget]".into(),
383                        status: StepStatus::Failed,
384                        message,
385                    });
386                    result.failed += 1;
387                    return result;
388                }
389                BudgetStatus::SoftExceeded { message, .. } => {
390                    crate::reporting::print_budget_warning(&message);
391                }
392                BudgetStatus::Ok => {}
393            }
394
395            if let Some(ms) = wait_ms {
396                std::thread::sleep(Duration::from_millis(ms));
397            }
398
399            result.details.push(step_result);
400        }
401
402        result
403    }
404
405    // ── step handlers ───────────────────────────────────────────────────
406
407    fn run_click(
408        &self,
409        target: &str,
410        selector_override: Option<&str>,
411        step_endpoint: Option<&str>,
412        test_endpoint: Option<&str>,
413        tab: &Tab,
414    ) -> StepResult {
415        let selector = match self.resolve_selector(
416            selector_override,
417            target,
418            step_endpoint,
419            test_endpoint,
420            tab,
421        ) {
422            Ok(s) => s,
423            Err(msg) => {
424                return StepResult {
425                    name: format!("[click] {target}"),
426                    status: StepStatus::Failed,
427                    message: msg,
428                };
429            }
430        };
431
432        match tab.wait_for_element(&selector) {
433            Ok(element) => match element.click() {
434                Ok(_) => StepResult {
435                    name: format!("[click] {target}"),
436                    status: StepStatus::Passed,
437                    message: format!("clicked {selector}"),
438                },
439                Err(e) => StepResult {
440                    name: format!("[click] {target}"),
441                    status: StepStatus::Failed,
442                    message: format!("click failed on {selector}: {e}"),
443                },
444            },
445            Err(e) => StepResult {
446                name: format!("[click] {target}"),
447                status: StepStatus::Failed,
448                message: format!("element {selector} not found: {e}"),
449            },
450        }
451    }
452
453    #[allow(clippy::too_many_arguments)]
454    fn run_type(
455        &self,
456        target: &str,
457        text: &str,
458        selector_override: Option<&str>,
459        step_endpoint: Option<&str>,
460        test_endpoint: Option<&str>,
461        tab: &Tab,
462    ) -> StepResult {
463        let selector = match self.resolve_selector(
464            selector_override,
465            target,
466            step_endpoint,
467            test_endpoint,
468            tab,
469        ) {
470            Ok(s) => s,
471            Err(msg) => {
472                return StepResult {
473                    name: format!("[type] {target}"),
474                    status: StepStatus::Failed,
475                    message: msg,
476                };
477            }
478        };
479
480        match tab.wait_for_element(&selector) {
481            Ok(element) => {
482                if let Err(e) = element.click() {
483                    return StepResult {
484                        name: format!("[type] {target}"),
485                        status: StepStatus::Failed,
486                        message: format!("click to focus {selector} failed: {e}"),
487                    };
488                }
489
490                let js = format!(
491                    "document.querySelector('{}').value = '';",
492                    selector.replace('\'', "\\'")
493                );
494                let _ = tab.evaluate(&js, false);
495
496                match element.type_into(text) {
497                    Ok(_) => StepResult {
498                        name: format!("[type] {target}"),
499                        status: StepStatus::Passed,
500                        message: format!("typed {text:?} into {selector}"),
501                    },
502                    Err(e) => StepResult {
503                        name: format!("[type] {target}"),
504                        status: StepStatus::Failed,
505                        message: format!("type into {selector} failed: {e}"),
506                    },
507                }
508            }
509            Err(e) => StepResult {
510                name: format!("[type] {target}"),
511                status: StepStatus::Failed,
512                message: format!("element {selector} not found: {e}"),
513            },
514        }
515    }
516
517    #[allow(clippy::too_many_arguments)]
518    fn run_wait(
519        &self,
520        target: &str,
521        selector_override: Option<&str>,
522        timeout_ms: Option<u64>,
523        step_endpoint: Option<&str>,
524        test_endpoint: Option<&str>,
525        tab: &Tab,
526    ) -> StepResult {
527        let selector = match self.resolve_selector(
528            selector_override,
529            target,
530            step_endpoint,
531            test_endpoint,
532            tab,
533        ) {
534            Ok(s) => s,
535            Err(msg) => {
536                return StepResult {
537                    name: format!("[wait] {target}"),
538                    status: StepStatus::Failed,
539                    message: msg,
540                };
541            }
542        };
543
544        let timeout = Duration::from_millis(timeout_ms.unwrap_or(10_000));
545
546        match tab.wait_for_element_with_custom_timeout(&selector, timeout) {
547            Ok(_) => StepResult {
548                name: format!("[wait] {target}"),
549                status: StepStatus::Passed,
550                message: format!("found {selector}"),
551            },
552            Err(e) => StepResult {
553                name: format!("[wait] {target}"),
554                status: StepStatus::Failed,
555                message: format!("wait for {selector} timed out: {e}"),
556            },
557        }
558    }
559
560    #[allow(clippy::too_many_arguments)]
561    fn run_assert(
562        &self,
563        definition: Option<&str>,
564        preset: Option<&str>,
565        prompt: Option<&str>,
566        assert_text: Option<&str>,
567        step_endpoint: Option<&str>,
568        test_endpoint: Option<&str>,
569        tab: &Tab,
570    ) -> StepResult {
571        std::thread::sleep(Duration::from_millis(500));
572
573        let page_content = get_page_text(tab);
574
575        if let Some(def_name) = definition {
576            if let Some(def) = self.definitions.get(def_name) {
577                return self.run_assert_def(def, &page_content, step_endpoint, test_endpoint);
578            }
579            return StepResult {
580                name: format!("[assert] {def_name}"),
581                status: StepStatus::Failed,
582                message: format!("definition '{def_name}' not found"),
583            };
584        }
585
586        if let Some(preset_name) = preset {
587            return self.run_preset(
588                preset_name,
589                assert_text,
590                &page_content,
591                step_endpoint,
592                test_endpoint,
593            );
594        }
595
596        if let Some(prompt_text) = prompt {
597            return self.run_custom(prompt_text, &page_content, step_endpoint, test_endpoint);
598        }
599
600        StepResult {
601            name: "[assert]".into(),
602            status: StepStatus::Skipped,
603            message: "no definition, preset, or prompt specified".into(),
604        }
605    }
606
607    fn run_assert_def(
608        &self,
609        def: &AssertDefinition,
610        page_content: &PageContent,
611        step_endpoint: Option<&str>,
612        test_endpoint: Option<&str>,
613    ) -> StepResult {
614        // Agent-based definition: delegate to an A2A agent
615        if let Some(ref agent) = def.agent {
616            let task = def
617                .task_template
618                .as_deref()
619                .unwrap_or("Evaluate the assertion")
620                .replace("{url}", &page_content.url)
621                .replace("{title}", &page_content.title)
622                .replace("{content}", &page_content.body_text)
623                .replace("{expected_text}", def.assert_text.as_deref().unwrap_or(""));
624
625            return self.run_agent_step(agent, &task, &def.name);
626        }
627
628        // Custom preset: system + user_template provided in the definition
629        if let (Some(system), Some(template)) = (&def.system, &def.user_template) {
630            return self.run_custom_preset(
631                &def.name,
632                system,
633                template,
634                def.assert_text.as_deref(),
635                page_content,
636                step_endpoint,
637                test_endpoint,
638            );
639        }
640
641        def.preset.as_ref().map_or_else(
642            || {
643                def.prompt.as_ref().map_or_else(
644                    || StepResult {
645                        name: format!("[assert] {}", def.name),
646                        status: StepStatus::Failed,
647                        message: "definition has no preset, prompt, or system+user_template".into(),
648                    },
649                    |prompt| self.run_custom(prompt, page_content, step_endpoint, test_endpoint),
650                )
651            },
652            |preset_name| {
653                self.run_preset(
654                    preset_name,
655                    def.assert_text.as_deref(),
656                    page_content,
657                    step_endpoint,
658                    test_endpoint,
659                )
660            },
661        )
662    }
663
664    #[allow(clippy::too_many_arguments)]
665    fn run_custom_preset(
666        &self,
667        name: &str,
668        system: &str,
669        template: &str,
670        assert_text: Option<&str>,
671        page_content: &PageContent,
672        step_endpoint: Option<&str>,
673        test_endpoint: Option<&str>,
674    ) -> StepResult {
675        let user_prompt = template
676            .replace("{url}", &page_content.url)
677            .replace("{title}", &page_content.title)
678            .replace("{content}", &page_content.body_text)
679            .replace("{expected_text}", assert_text.unwrap_or(""))
680            .replace("{description}", "");
681
682        eprintln!("      assert: {name} (custom preset)");
683
684        let endpoint = self
685            .endpoints
686            .resolve(step_endpoint.or(test_endpoint), TaskType::Assertion);
687        let llm = self.build_llm_for_endpoint(endpoint);
688        let usage = Arc::clone(&self.usage);
689        let endpoint_name = endpoint.name.clone();
690        let sys = system.to_owned();
691
692        let response = std::thread::spawn(move || {
693            let rt = tokio::runtime::Builder::new_current_thread()
694                .enable_all()
695                .build()
696                .unwrap();
697            rt.block_on(llm_chat_with_usage(&llm, &sys, &user_prompt))
698        })
699        .join()
700        .unwrap();
701
702        response.map_or_else(
703            || StepResult {
704                name: format!("[assert] {name}"),
705                status: StepStatus::Failed,
706                message: "LLM assertion call failed (server down?)".into(),
707            },
708            |lr| {
709                usage.record_llm_call(
710                    &endpoint_name,
711                    endpoint,
712                    lr.usage.prompt_tokens,
713                    lr.usage.completion_tokens,
714                );
715                let content_lower = lr.content.to_lowercase().trim().to_owned();
716                if content_lower.starts_with("pass") {
717                    StepResult {
718                        name: format!("[assert] {name}"),
719                        status: StepStatus::Passed,
720                        message: "PASS".into(),
721                    }
722                } else {
723                    StepResult {
724                        name: format!("[assert] {name}"),
725                        status: StepStatus::Failed,
726                        message: lr.content,
727                    }
728                }
729            },
730        )
731    }
732
733    fn run_preset(
734        &self,
735        preset_name: &str,
736        assert_text: Option<&str>,
737        page_content: &PageContent,
738        step_endpoint: Option<&str>,
739        test_endpoint: Option<&str>,
740    ) -> StepResult {
741        let Some(preset) = ASSERTION_PRESETS.iter().find(|p| p.name == preset_name) else {
742            return StepResult {
743                name: format!("[assert] {preset_name}"),
744                status: StepStatus::Failed,
745                message: format!("unknown assertion preset: {preset_name}"),
746            };
747        };
748
749        let user_prompt = preset
750            .user_template
751            .replace("{url}", &page_content.url)
752            .replace("{title}", &page_content.title)
753            .replace("{content}", &page_content.body_text)
754            .replace("{expected_text}", assert_text.unwrap_or(""))
755            .replace("{description}", "");
756
757        eprintln!("      assert: {preset_name}");
758
759        let endpoint = self
760            .endpoints
761            .resolve(step_endpoint.or(test_endpoint), TaskType::Assertion);
762        let llm = self.build_llm_for_endpoint(endpoint);
763        let usage = Arc::clone(&self.usage);
764        let endpoint_name = endpoint.name.clone();
765        let sys = preset.system.to_owned();
766
767        let response = std::thread::spawn(move || {
768            let rt = tokio::runtime::Builder::new_current_thread()
769                .enable_all()
770                .build()
771                .unwrap();
772            rt.block_on(llm_chat_with_usage(&llm, &sys, &user_prompt))
773        })
774        .join()
775        .unwrap();
776
777        response.map_or_else(
778            || StepResult {
779                name: format!("[assert] {preset_name}"),
780                status: StepStatus::Failed,
781                message: "LLM assertion call failed (server down?)".into(),
782            },
783            |lr| {
784                usage.record_llm_call(
785                    &endpoint_name,
786                    endpoint,
787                    lr.usage.prompt_tokens,
788                    lr.usage.completion_tokens,
789                );
790                let content_lower = lr.content.to_lowercase().trim().to_owned();
791                if content_lower.starts_with("pass") {
792                    StepResult {
793                        name: format!("[assert] {preset_name}"),
794                        status: StepStatus::Passed,
795                        message: "PASS".into(),
796                    }
797                } else {
798                    StepResult {
799                        name: format!("[assert] {preset_name}"),
800                        status: StepStatus::Failed,
801                        message: lr.content,
802                    }
803                }
804            },
805        )
806    }
807
808    fn run_custom(
809        &self,
810        prompt: &str,
811        page_content: &PageContent,
812        step_endpoint: Option<&str>,
813        test_endpoint: Option<&str>,
814    ) -> StepResult {
815        let system = "You are a QA tester evaluating a web page. Respond with exactly \"PASS\" if the assertion holds, or \"FAIL: <reason>\" if it does not.";
816
817        let user = format!(
818            "Page URL: {url}\nPage Title: {title}\n\nPage Content:\n{content}\n\nAssertion: {prompt}",
819            url = page_content.url,
820            title = page_content.title,
821            content = page_content.body_text,
822        );
823
824        eprintln!("      custom assert");
825
826        let endpoint = self
827            .endpoints
828            .resolve(step_endpoint.or(test_endpoint), TaskType::Assertion);
829        let llm = self.build_llm_for_endpoint(endpoint);
830        let usage = Arc::clone(&self.usage);
831        let endpoint_name = endpoint.name.clone();
832        let sys = system.to_owned();
833
834        let response = std::thread::spawn(move || {
835            let rt = tokio::runtime::Builder::new_current_thread()
836                .enable_all()
837                .build()
838                .unwrap();
839            rt.block_on(llm_chat_with_usage(&llm, &sys, &user))
840        })
841        .join()
842        .unwrap();
843
844        response.map_or_else(
845            || StepResult {
846                name: "[assert] custom".into(),
847                status: StepStatus::Failed,
848                message: "LLM assertion call failed (server down?)".into(),
849            },
850            |lr| {
851                usage.record_llm_call(
852                    &endpoint_name,
853                    endpoint,
854                    lr.usage.prompt_tokens,
855                    lr.usage.completion_tokens,
856                );
857                let content_lower = lr.content.to_lowercase().trim().to_owned();
858                if content_lower.starts_with("pass") {
859                    StepResult {
860                        name: "[assert] custom".into(),
861                        status: StepStatus::Passed,
862                        message: "PASS".into(),
863                    }
864                } else {
865                    StepResult {
866                        name: "[assert] custom".into(),
867                        status: StepStatus::Failed,
868                        message: lr.content,
869                    }
870                }
871            },
872        )
873    }
874
875    fn run_screenshot(path: Option<&str>, tab: &Tab) -> StepResult {
876        let path = path.unwrap_or("screenshot.png");
877
878        match tab.capture_screenshot(
879            headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption::Png,
880            None,
881            None,
882            true,
883        ) {
884            Ok(data) => {
885                if let Err(e) = std::fs::write(path, &data) {
886                    return StepResult {
887                        name: format!("[screenshot] {path}"),
888                        status: StepStatus::Failed,
889                        message: format!("failed to write screenshot: {e}"),
890                    };
891                }
892                StepResult {
893                    name: format!("[screenshot] {path}"),
894                    status: StepStatus::Passed,
895                    message: format!("saved to {path}"),
896                }
897            }
898            Err(e) => StepResult {
899                name: format!("[screenshot] {path}"),
900                status: StepStatus::Failed,
901                message: format!("screenshot failed: {e}"),
902            },
903        }
904    }
905
906    /// Runs an A2A agent step.
907    #[allow(clippy::literal_string_with_formatting_args)]
908    fn run_agent(
909        &self,
910        agent_name: &str,
911        task: &str,
912        definition: Option<&str>,
913        _test_endpoint: Option<&str>,
914    ) -> StepResult {
915        // If a definition is specified, look up the task template
916        let resolved_task = if let Some(def_name) = definition {
917            if let Some(def) = self.definitions.get(def_name) {
918                let tmpl = def.task_template.as_deref().unwrap_or(task);
919                tmpl.replace("{task}", task)
920            } else {
921                return StepResult {
922                    name: format!("[agent] {def_name}"),
923                    status: StepStatus::Failed,
924                    message: format!("definition '{def_name}' not found"),
925                };
926            }
927        } else {
928            task.to_owned()
929        };
930
931        self.run_agent_step(agent_name, &resolved_task, &format!("agent:{agent_name}"))
932    }
933
934    fn run_agent_step(&self, agent_name: &str, task: &str, display_name: &str) -> StepResult {
935        let Some(ep) = self.endpoints.get(agent_name) else {
936            return StepResult {
937                name: format!("[agent] {display_name}"),
938                status: StepStatus::Failed,
939                message: format!("agent endpoint '{agent_name}' not found"),
940            };
941        };
942
943        if ep.url.is_empty() {
944            return StepResult {
945                name: format!("[agent] {display_name}"),
946                status: StepStatus::Failed,
947                message: format!("agent endpoint '{agent_name}' has no URL"),
948            };
949        }
950
951        eprintln!("      → agent {agent_name}: {task}");
952
953        let url = ep.url.clone();
954        let client = A2aClient::new(&url, self.timeout);
955        let task_clone = task.to_owned();
956
957        let response = std::thread::spawn(move || {
958            let rt = tokio::runtime::Builder::new_current_thread()
959                .enable_all()
960                .build()
961                .unwrap();
962            rt.block_on(client.send_task(&task_clone))
963        })
964        .join()
965        .unwrap();
966
967        // Record the flat-cost call
968        self.usage.record_flat_call(agent_name, ep);
969
970        match response {
971            Ok(text) => {
972                let clean = text.trim().to_owned();
973                let lower = clean.to_lowercase();
974                if lower.starts_with("pass") {
975                    StepResult {
976                        name: format!("[agent] {display_name}"),
977                        status: StepStatus::Passed,
978                        message: format!("PASS: {clean}"),
979                    }
980                } else if lower.starts_with("fail") {
981                    StepResult {
982                        name: format!("[agent] {display_name}"),
983                        status: StepStatus::Failed,
984                        message: clean,
985                    }
986                } else {
987                    StepResult {
988                        name: format!("[agent] {display_name}"),
989                        status: StepStatus::Passed,
990                        message: format!("response: {clean}"),
991                    }
992                }
993            }
994            Err(e) => StepResult {
995                name: format!("[agent] {display_name}"),
996                status: StepStatus::Failed,
997                message: format!("agent call failed: {e}"),
998            },
999        }
1000    }
1001
1002    /// Runs an MCP tool call step.
1003    fn run_mcp(
1004        &self,
1005        server_name: &str,
1006        tool_name: &str,
1007        args: Option<&serde_json::Value>,
1008    ) -> StepResult {
1009        let Some(ep) = self.endpoints.get(server_name) else {
1010            return StepResult {
1011                name: format!("[mcp] {server_name}:{tool_name}"),
1012                status: StepStatus::Failed,
1013                message: format!("MCP server endpoint '{server_name}' not found"),
1014            };
1015        };
1016
1017        let cmd = ep.command.as_deref().unwrap_or("");
1018        if cmd.is_empty() {
1019            return StepResult {
1020                name: format!("[mcp] {server_name}:{tool_name}"),
1021                status: StepStatus::Failed,
1022                message: format!("MCP server '{server_name}' has no command configured"),
1023            };
1024        }
1025
1026        eprintln!("      → mcp {server_name} {tool_name}");
1027
1028        let args_val = args.cloned().unwrap_or(serde_json::Value::Null);
1029
1030        let command = cmd.to_owned();
1031        let args_vec = ep.args.clone();
1032        let tool = tool_name.to_owned();
1033
1034        let response = std::thread::spawn(move || {
1035            let mut mcp_client =
1036                McpClient::connect_stdio(&command, &args_vec).map_err(|e| e.to_string())?;
1037            mcp_client
1038                .call_tool(&tool, &args_val)
1039                .map_err(|e| e.to_string())
1040        })
1041        .join()
1042        .unwrap();
1043
1044        // Record the flat-cost call
1045        self.usage.record_flat_call(server_name, ep);
1046
1047        match response {
1048            Ok(result) => {
1049                if result.isError {
1050                    StepResult {
1051                        name: format!("[mcp] {server_name}:{tool_name}"),
1052                        status: StepStatus::Failed,
1053                        message: result.to_string(),
1054                    }
1055                } else {
1056                    StepResult {
1057                        name: format!("[mcp] {server_name}:{tool_name}"),
1058                        status: StepStatus::Passed,
1059                        message: result.to_string(),
1060                    }
1061                }
1062            }
1063            Err(e) => StepResult {
1064                name: format!("[mcp] {server_name}:{tool_name}"),
1065                status: StepStatus::Failed,
1066                message: format!("MCP call failed: {e}"),
1067            },
1068        }
1069    }
1070
1071    // ── helpers ──────────────────────────────────────────────────────────
1072
1073    /// Builds an `LlmConfig` from a resolved endpoint, falling back to
1074    /// the runner's default LLM config for any unset fields.
1075    fn build_llm_for_endpoint(&self, endpoint: &crate::endpoints::ResolvedEndpoint) -> LlmConfig {
1076        LlmConfig {
1077            url: if endpoint.url.is_empty() {
1078                self.llm.url.clone()
1079            } else {
1080                endpoint.url.clone()
1081            },
1082            model: endpoint
1083                .model
1084                .clone()
1085                .unwrap_or_else(|| self.llm.model.clone()),
1086            api_key: endpoint
1087                .api_key
1088                .clone()
1089                .or_else(|| self.llm.api_key.clone()),
1090            headers: if endpoint.headers.is_empty() {
1091                self.llm.headers.clone()
1092            } else {
1093                endpoint.headers.clone()
1094            },
1095            timeout: self.llm.timeout,
1096            temperature: self.llm.temperature,
1097            thinking: self.llm.thinking,
1098            model_params: self.llm.model_params.clone(),
1099        }
1100    }
1101
1102    /// Resolves a CSS selector for the target element. Uses the explicit
1103    /// `selector` if provided, otherwise asks the LLM to find the element
1104    /// from the natural language `target` description and page DOM.
1105    fn resolve_selector(
1106        &self,
1107        css_override: Option<&str>,
1108        target: &str,
1109        step_endpoint: Option<&str>,
1110        test_endpoint: Option<&str>,
1111        tab: &Tab,
1112    ) -> Result<String, String> {
1113        if let Some(explicit) = css_override {
1114            return Ok(explicit.to_owned());
1115        }
1116
1117        let dom_info = extract_dom_info(tab)?;
1118        let page_content = get_page_text(tab);
1119
1120        let system = concat!(
1121            "You are a browser automation selector generator. ",
1122            "Given a web page's content and interactive elements, ",
1123            "return ONLY the best CSS selector for the described element. ",
1124            "Output nothing except the CSS selector. ",
1125            "Prefer selectors in this order: #id, [data-testid=\"...\"], ",
1126            "[name=\"...\"], tag.class, tag. ",
1127            "Never output explanations, markdown, or extra text."
1128        );
1129
1130        let user = format!(
1131            "Page URL: {}\nPage Title: {}\n\nPage body text (first 4000 chars):\n{}\n\nInteractive elements:\n{}\n\nFind the CSS selector for: {}",
1132            page_content.url,
1133            page_content.title,
1134            truncate(&page_content.body_text, 4000),
1135            dom_info,
1136            target,
1137        );
1138
1139        eprintln!("      LLM targeting: {target}");
1140
1141        let endpoint = self
1142            .endpoints
1143            .resolve(step_endpoint.or(test_endpoint), TaskType::Targeting);
1144        let llm = self.build_llm_for_endpoint(endpoint);
1145        let usage = Arc::clone(&self.usage);
1146        let endpoint_name = endpoint.name.clone();
1147        let endpoint_clone = endpoint.clone();
1148        let sys = system.to_owned();
1149
1150        let selector = std::thread::spawn(move || {
1151            let rt = tokio::runtime::Builder::new_current_thread()
1152                .enable_all()
1153                .build()
1154                .unwrap();
1155            rt.block_on(llm_chat_with_usage(&llm, &sys, &user))
1156        })
1157        .join()
1158        .unwrap();
1159
1160        match selector {
1161            Some(lr) => {
1162                usage.record_llm_call(
1163                    &endpoint_name,
1164                    &endpoint_clone,
1165                    lr.usage.prompt_tokens,
1166                    lr.usage.completion_tokens,
1167                );
1168                let clean = lr
1169                    .content
1170                    .trim()
1171                    .trim_matches('"')
1172                    .trim_matches('\'')
1173                    .trim_matches('`')
1174                    .to_owned();
1175                eprintln!("      resolved selector: {clean}");
1176                Ok(clean)
1177            }
1178            None => Err("LLM element targeting failed (server down?)".to_owned()),
1179        }
1180    }
1181}
1182
1183// ── Free helper functions ──────────────────────────────────────────────
1184
1185fn run_navigate_step(full_url: &str, tab: &Tab) -> StepResult {
1186    let name = format!("[navigate] {full_url}");
1187    match tab.navigate_to(full_url) {
1188        Ok(_) => {
1189            let _ = tab.wait_until_navigated();
1190            StepResult {
1191                name,
1192                status: StepStatus::Passed,
1193                message: format!("navigated to {full_url}"),
1194            }
1195        }
1196        Err(e) => StepResult {
1197            name,
1198            status: StepStatus::Failed,
1199            message: format!("navigation failed: {e}"),
1200        },
1201    }
1202}
1203
1204fn extract_dom_info(tab: &Tab) -> Result<String, String> {
1205    let result = tab
1206        .evaluate(DOM_EXTRACT_JS, false)
1207        .map_err(|e| format!("DOM extraction failed: {e}"))?;
1208
1209    let json_str = result
1210        .value
1211        .as_ref()
1212        .and_then(|v| v.as_str())
1213        .unwrap_or("[]");
1214
1215    let elements: Vec<String> = serde_json::from_str(json_str).unwrap_or_default();
1216
1217    if elements.is_empty() {
1218        return Ok("(no interactive elements found)".to_owned());
1219    }
1220
1221    Ok(elements.join("\n"))
1222}
1223
1224fn get_page_text(tab: &Tab) -> PageContent {
1225    let url = tab.get_url();
1226
1227    let title = tab
1228        .evaluate("document.title", false)
1229        .ok()
1230        .and_then(|r| r.value)
1231        .and_then(|v| v.as_str().map(String::from))
1232        .unwrap_or_else(|| "unknown".to_owned());
1233
1234    let body_text = tab
1235        .evaluate("document.body ? document.body.innerText : ''", false)
1236        .ok()
1237        .and_then(|r| r.value)
1238        .and_then(|v| v.as_str().map(String::from))
1239        .unwrap_or_default();
1240
1241    PageContent {
1242        url,
1243        title,
1244        body_text: truncate(&body_text, 8000),
1245    }
1246}
1247
1248fn resolve_url(url: &str, base_url: &str) -> String {
1249    if url.starts_with("http://") || url.starts_with("https://") {
1250        return url.to_owned();
1251    }
1252    let base = base_url.trim_end_matches('/');
1253    if url.starts_with('/') {
1254        format!("{base}{url}")
1255    } else {
1256        format!("{base}/{url}")
1257    }
1258}
1259
1260// ── Support types ──────────────────────────────────────────────────────
1261
1262#[derive(Default)]
1263struct TestRunResult {
1264    passed: u32,
1265    failed: u32,
1266    skipped: u32,
1267    total: u32,
1268    details: Vec<StepResult>,
1269}
1270
1271struct PageContent {
1272    url: String,
1273    title: String,
1274    body_text: String,
1275}