Skip to main content

llm_browser_testkit/
runner.rs

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