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