Skip to main content

llm_browser_testkit/
runner.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use anyhow::Context;
5use headless_chrome::{Browser, LaunchOptions, Tab};
6
7use crate::llm_chat;
8use crate::scenario::{AssertDefinition, ScenarioConfig, TestGroup, TestStep};
9use crate::truncate;
10use crate::LlmConfig;
11use crate::DOM_EXTRACT_JS;
12
13/// Executes a [`Scenario`] against a real browser with optional LLM
14/// assistance for element targeting and assertions.
15pub struct ScenarioRunner {
16    config: ScenarioConfig,
17    definitions: HashMap<String, AssertDefinition>,
18    llm: LlmConfig,
19    timeout: Duration,
20    viewport_width: u32,
21    viewport_height: u32,
22}
23
24/// Aggregated results from a scenario run.
25#[derive(Debug, Default)]
26pub struct RunReport {
27    /// Number of tests that passed.
28    pub tests_passed: u32,
29    /// Number of tests that failed.
30    pub tests_failed: u32,
31    /// Number of steps that passed.
32    pub passed: u32,
33    /// Number of steps that failed.
34    pub failed: u32,
35    /// Number of steps that were skipped.
36    pub skipped: u32,
37    /// Per-step details.
38    pub details: Vec<StepResult>,
39}
40
41/// Result of a single step execution.
42#[derive(Debug)]
43pub struct StepResult {
44    /// The step name.
45    pub name: String,
46    /// Whether the step passed, failed, or was skipped.
47    pub status: StepStatus,
48    /// Human-readable result message.
49    pub message: String,
50}
51
52/// Outcome for a single step.
53#[derive(Debug, PartialEq, Eq)]
54pub enum StepStatus {
55    /// Step executed successfully and all assertions passed.
56    Passed,
57    /// Step execution or assertion failed.
58    Failed,
59    /// Step was skipped.
60    Skipped,
61}
62
63/// Predefined assertion preset definition.
64struct AssertPreset {
65    name: &'static str,
66    system: &'static str,
67    user_template: &'static str,
68}
69
70/// Built-in assertion presets.
71#[allow(clippy::literal_string_with_formatting_args)]
72const ASSERTION_PRESETS: &[AssertPreset] = &[
73    AssertPreset {
74        name: "no_error_on_page",
75        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.",
76        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.",
77    },
78    AssertPreset {
79        name: "text_visible",
80        system: "You are a QA tester. Your task is to check if specific text is visible in the page content.",
81        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.",
82    },
83    AssertPreset {
84        name: "element_exists",
85        system: "You are a QA tester. Check if a described UI element exists on a web page.",
86        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.",
87    },
88];
89
90impl ScenarioRunner {
91    /// Creates a new runner with the given scenario configuration and
92    /// assertion definitions.
93    #[must_use]
94    pub fn new(scenario_config: ScenarioConfig, definitions: Vec<AssertDefinition>) -> Self {
95        let llm = LlmConfig {
96            url: scenario_config
97                .llm_url
98                .clone()
99                .unwrap_or_else(crate::llm_base_url),
100            model: scenario_config
101                .llm_model
102                .clone()
103                .unwrap_or_else(crate::llm_model),
104            api_key: scenario_config
105                .llm_api_key
106                .clone()
107                .or_else(|| std::env::var("HARNESS_LLM_API_KEY").ok()),
108            headers: if scenario_config.llm_headers.is_empty() {
109                crate::parse_headers_env()
110            } else {
111                scenario_config.llm_headers.clone()
112            },
113            timeout: Duration::from_secs(scenario_config.timeout_secs.unwrap_or(60)),
114            temperature: scenario_config.temperature,
115            thinking: scenario_config.thinking,
116            model_params: scenario_config.model_params.clone(),
117        };
118        let timeout = Duration::from_secs(scenario_config.timeout_secs.unwrap_or(60));
119        let viewport_width = scenario_config.viewport_width.unwrap_or(1280);
120        let viewport_height = scenario_config.viewport_height.unwrap_or(720);
121        let defs_map: HashMap<String, AssertDefinition> = definitions
122            .into_iter()
123            .map(|d| (d.name.clone(), d))
124            .collect();
125
126        Self {
127            config: scenario_config,
128            definitions: defs_map,
129            llm,
130            timeout,
131            viewport_width,
132            viewport_height,
133        }
134    }
135
136    /// Executes all test groups in the scenario and returns a report.
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if the browser fails to launch.
141    pub fn run(&self, tests: &[TestGroup]) -> anyhow::Result<RunReport> {
142        let mut report = RunReport::default();
143
144        if tests.is_empty() {
145            eprintln!("No tests defined in scenario.");
146            return Ok(report);
147        }
148
149        let browser_headless = self.config.browser_headless.unwrap_or(true);
150
151        let launch_opts = LaunchOptions {
152            headless: browser_headless,
153            window_size: Some((self.viewport_width, self.viewport_height)),
154            sandbox: false,
155            ..LaunchOptions::default()
156        };
157
158        let browser = Browser::new(launch_opts).context("failed to launch browser")?;
159        let tab = browser.new_tab().context("failed to open browser tab")?;
160        let _ = tab.set_default_timeout(self.timeout);
161
162        for test in tests {
163            eprintln!("\n══════════════════════════════");
164            eprintln!("  TEST: {}", test.name);
165            eprintln!("══════════════════════════════");
166
167            let test_result = self.run_test(test, &tab);
168            if test_result.failed == 0 && test_result.total > 0 {
169                report.tests_passed += 1;
170                eprintln!("  TEST ✅ PASSED");
171            } else if test_result.total > 0 {
172                report.tests_failed += 1;
173                eprintln!("  TEST ❌ FAILED");
174            }
175
176            report.passed += test_result.passed;
177            report.failed += test_result.failed;
178            report.skipped += test_result.skipped;
179            report.details.extend(test_result.details);
180        }
181
182        Ok(report)
183    }
184
185    fn run_test(&self, test: &TestGroup, tab: &Tab) -> TestRunResult {
186        let base_url = test
187            .base_url
188            .clone()
189            .or_else(|| self.config.base_url.clone())
190            .unwrap_or_else(crate::base_url);
191
192        let auto_navigate = test.auto_navigate.unwrap_or(self.config.auto_navigate);
193
194        let start_url = test
195            .start_url
196            .clone()
197            .or_else(|| self.config.start_url.clone())
198            .unwrap_or_else(|| "/dashboard".to_owned());
199
200        if auto_navigate {
201            let full_url = resolve_url(&start_url, &base_url);
202            eprintln!("  → auto-navigate: {full_url}");
203            let _ = tab.navigate_to(&full_url);
204            let _ = tab.wait_until_navigated();
205            std::thread::sleep(Duration::from_secs(4));
206        }
207
208        let mut result = TestRunResult {
209            passed: 0,
210            failed: 0,
211            skipped: 0,
212            total: 0,
213            details: Vec::new(),
214        };
215
216        for step in &test.steps {
217            result.total += 1;
218
219            // Extract per-step wait_after_ms before the step consumes the data
220            let wait_ms = match step {
221                TestStep::Navigate { wait_after_ms, .. }
222                | TestStep::Click { wait_after_ms, .. }
223                | TestStep::Type { wait_after_ms, .. } => *wait_after_ms,
224                _ => None,
225            };
226
227            let step_result = match step {
228                TestStep::Navigate { url, .. } => {
229                    let full_url = resolve_url(url, &base_url);
230                    run_navigate_step(&full_url, tab)
231                }
232                TestStep::Click {
233                    target, selector, ..
234                } => self.run_click(target, selector.as_deref(), tab),
235                TestStep::Type {
236                    target,
237                    text,
238                    selector,
239                    ..
240                } => self.run_type(target, text, selector.as_deref(), tab),
241                TestStep::Wait {
242                    target,
243                    selector,
244                    timeout_ms,
245                } => self.run_wait(target, selector.as_deref(), *timeout_ms, tab),
246                TestStep::Assert {
247                    definition,
248                    preset,
249                    prompt,
250                    assert_text,
251                } => self.run_assert(
252                    definition.as_deref(),
253                    preset.as_deref(),
254                    prompt.as_deref(),
255                    assert_text.as_deref(),
256                    tab,
257                ),
258                TestStep::Screenshot { path } => Self::run_screenshot(path.as_deref(), tab),
259            };
260
261            eprintln!(
262                "    {} {} — {}",
263                if step_result.status == StepStatus::Passed {
264                    "✅"
265                } else if step_result.status == StepStatus::Failed {
266                    "❌"
267                } else {
268                    "⏭️"
269                },
270                step_result.name,
271                step_result.message,
272            );
273
274            match step_result.status {
275                StepStatus::Passed => result.passed += 1,
276                StepStatus::Failed => result.failed += 1,
277                StepStatus::Skipped => result.skipped += 1,
278            }
279
280            if let Some(ms) = wait_ms {
281                std::thread::sleep(Duration::from_millis(ms));
282            }
283
284            result.details.push(step_result);
285        }
286
287        result
288    }
289
290    // ── step handlers ───────────────────────────────────────────────────
291
292    fn run_click(&self, target: &str, selector_override: Option<&str>, tab: &Tab) -> StepResult {
293        let selector = match self.resolve_selector(selector_override, target, tab) {
294            Ok(s) => s,
295            Err(msg) => {
296                return StepResult {
297                    name: format!("[click] {target}"),
298                    status: StepStatus::Failed,
299                    message: msg,
300                };
301            }
302        };
303
304        match tab.wait_for_element(&selector) {
305            Ok(element) => match element.click() {
306                Ok(_) => StepResult {
307                    name: format!("[click] {target}"),
308                    status: StepStatus::Passed,
309                    message: format!("clicked {selector}"),
310                },
311                Err(e) => StepResult {
312                    name: format!("[click] {target}"),
313                    status: StepStatus::Failed,
314                    message: format!("click failed on {selector}: {e}"),
315                },
316            },
317            Err(e) => StepResult {
318                name: format!("[click] {target}"),
319                status: StepStatus::Failed,
320                message: format!("element {selector} not found: {e}"),
321            },
322        }
323    }
324
325    fn run_type(
326        &self,
327        target: &str,
328        text: &str,
329        selector_override: Option<&str>,
330        tab: &Tab,
331    ) -> StepResult {
332        let selector = match self.resolve_selector(selector_override, target, tab) {
333            Ok(s) => s,
334            Err(msg) => {
335                return StepResult {
336                    name: format!("[type] {target}"),
337                    status: StepStatus::Failed,
338                    message: msg,
339                };
340            }
341        };
342
343        match tab.wait_for_element(&selector) {
344            Ok(element) => {
345                if let Err(e) = element.click() {
346                    return StepResult {
347                        name: format!("[type] {target}"),
348                        status: StepStatus::Failed,
349                        message: format!("click to focus {selector} failed: {e}"),
350                    };
351                }
352
353                let js = format!(
354                    "document.querySelector('{}').value = '';",
355                    selector.replace('\'', "\\'")
356                );
357                let _ = tab.evaluate(&js, false);
358
359                match element.type_into(text) {
360                    Ok(_) => StepResult {
361                        name: format!("[type] {target}"),
362                        status: StepStatus::Passed,
363                        message: format!("typed {text:?} into {selector}"),
364                    },
365                    Err(e) => StepResult {
366                        name: format!("[type] {target}"),
367                        status: StepStatus::Failed,
368                        message: format!("type into {selector} failed: {e}"),
369                    },
370                }
371            }
372            Err(e) => StepResult {
373                name: format!("[type] {target}"),
374                status: StepStatus::Failed,
375                message: format!("element {selector} not found: {e}"),
376            },
377        }
378    }
379
380    fn run_wait(
381        &self,
382        target: &str,
383        selector_override: Option<&str>,
384        timeout_ms: Option<u64>,
385        tab: &Tab,
386    ) -> StepResult {
387        let selector = match self.resolve_selector(selector_override, target, tab) {
388            Ok(s) => s,
389            Err(msg) => {
390                return StepResult {
391                    name: format!("[wait] {target}"),
392                    status: StepStatus::Failed,
393                    message: msg,
394                };
395            }
396        };
397
398        let timeout = Duration::from_millis(timeout_ms.unwrap_or(10_000));
399
400        match tab.wait_for_element_with_custom_timeout(&selector, timeout) {
401            Ok(_) => StepResult {
402                name: format!("[wait] {target}"),
403                status: StepStatus::Passed,
404                message: format!("found {selector}"),
405            },
406            Err(e) => StepResult {
407                name: format!("[wait] {target}"),
408                status: StepStatus::Failed,
409                message: format!("wait for {selector} timed out: {e}"),
410            },
411        }
412    }
413
414    fn run_assert(
415        &self,
416        definition: Option<&str>,
417        preset: Option<&str>,
418        prompt: Option<&str>,
419        assert_text: Option<&str>,
420        tab: &Tab,
421    ) -> StepResult {
422        std::thread::sleep(Duration::from_millis(500));
423
424        let page_content = get_page_text(tab);
425
426        if let Some(def_name) = definition {
427            if let Some(def) = self.definitions.get(def_name) {
428                return self.run_assert_def(def, &page_content);
429            }
430            return StepResult {
431                name: format!("[assert] {def_name}"),
432                status: StepStatus::Failed,
433                message: format!("definition '{def_name}' not found"),
434            };
435        }
436
437        if let Some(preset_name) = preset {
438            return self.run_preset(preset_name, assert_text, &page_content);
439        }
440
441        if let Some(prompt_text) = prompt {
442            return self.run_custom(prompt_text, &page_content);
443        }
444
445        StepResult {
446            name: "[assert]".into(),
447            status: StepStatus::Skipped,
448            message: "no definition, preset, or prompt specified".into(),
449        }
450    }
451
452    fn run_assert_def(&self, def: &AssertDefinition, page_content: &PageContent) -> StepResult {
453        // Custom preset: system + user_template provided in the definition
454        if let (Some(system), Some(template)) = (&def.system, &def.user_template) {
455            return self.run_custom_preset(
456                &def.name,
457                system,
458                template,
459                def.assert_text.as_deref(),
460                page_content,
461            );
462        }
463
464        def.preset.as_ref().map_or_else(
465            || {
466                def.prompt.as_ref().map_or_else(
467                    || StepResult {
468                        name: format!("[assert] {}", def.name),
469                        status: StepStatus::Failed,
470                        message: "definition has no preset, prompt, or system+user_template".into(),
471                    },
472                    |prompt| self.run_custom(prompt, page_content),
473                )
474            },
475            |preset_name| self.run_preset(preset_name, def.assert_text.as_deref(), page_content),
476        )
477    }
478
479    fn run_custom_preset(
480        &self,
481        name: &str,
482        system: &str,
483        template: &str,
484        assert_text: Option<&str>,
485        page_content: &PageContent,
486    ) -> StepResult {
487        let user_prompt = template
488            .replace("{url}", &page_content.url)
489            .replace("{title}", &page_content.title)
490            .replace("{content}", &page_content.body_text)
491            .replace("{expected_text}", assert_text.unwrap_or(""))
492            .replace("{description}", "");
493
494        eprintln!("      assert: {name} (custom preset)");
495
496        let (llm, sys, prompt) = (self.llm.clone(), system.to_owned(), user_prompt);
497        let response = std::thread::spawn(move || {
498            let rt = tokio::runtime::Builder::new_current_thread()
499                .enable_all()
500                .build()
501                .unwrap();
502            rt.block_on(llm_chat(&llm, &sys, &prompt))
503        })
504        .join()
505        .unwrap();
506
507        response.map_or_else(
508            || StepResult {
509                name: format!("[assert] {name}"),
510                status: StepStatus::Failed,
511                message: "LLM assertion call failed (server down?)".into(),
512            },
513            |content| {
514                let content_lower = content.to_lowercase().trim().to_owned();
515                if content_lower.starts_with("pass") {
516                    StepResult {
517                        name: format!("[assert] {name}"),
518                        status: StepStatus::Passed,
519                        message: "PASS".into(),
520                    }
521                } else {
522                    StepResult {
523                        name: format!("[assert] {name}"),
524                        status: StepStatus::Failed,
525                        message: content,
526                    }
527                }
528            },
529        )
530    }
531
532    fn run_preset(
533        &self,
534        preset_name: &str,
535        assert_text: Option<&str>,
536        page_content: &PageContent,
537    ) -> StepResult {
538        let Some(preset) = ASSERTION_PRESETS.iter().find(|p| p.name == preset_name) else {
539            return StepResult {
540                name: format!("[assert] {preset_name}"),
541                status: StepStatus::Failed,
542                message: format!("unknown assertion preset: {preset_name}"),
543            };
544        };
545
546        let user_prompt = preset
547            .user_template
548            .replace("{url}", &page_content.url)
549            .replace("{title}", &page_content.title)
550            .replace("{content}", &page_content.body_text)
551            .replace("{expected_text}", assert_text.unwrap_or(""))
552            .replace("{description}", "");
553
554        eprintln!("      assert: {preset_name}");
555
556        let (llm, sys, prompt) = (self.llm.clone(), preset.system.to_owned(), user_prompt);
557        let response = std::thread::spawn(move || {
558            let rt = tokio::runtime::Builder::new_current_thread()
559                .enable_all()
560                .build()
561                .unwrap();
562            rt.block_on(llm_chat(&llm, &sys, &prompt))
563        })
564        .join()
565        .unwrap();
566
567        response.map_or_else(
568            || StepResult {
569                name: format!("[assert] {preset_name}"),
570                status: StepStatus::Failed,
571                message: "LLM assertion call failed (server down?)".into(),
572            },
573            |content| {
574                let content_lower = content.to_lowercase().trim().to_owned();
575                if content_lower.starts_with("pass") {
576                    StepResult {
577                        name: format!("[assert] {preset_name}"),
578                        status: StepStatus::Passed,
579                        message: "PASS".into(),
580                    }
581                } else {
582                    StepResult {
583                        name: format!("[assert] {preset_name}"),
584                        status: StepStatus::Failed,
585                        message: content,
586                    }
587                }
588            },
589        )
590    }
591
592    fn run_custom(&self, prompt: &str, page_content: &PageContent) -> StepResult {
593        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.";
594
595        let user = format!(
596            "Page URL: {url}\nPage Title: {title}\n\nPage Content:\n{content}\n\nAssertion: {prompt}",
597            url = page_content.url,
598            title = page_content.title,
599            content = page_content.body_text,
600        );
601
602        eprintln!("      custom assert");
603
604        let (llm, sys, user_prompt) = (self.llm.clone(), system.to_owned(), user);
605        let response = std::thread::spawn(move || {
606            let rt = tokio::runtime::Builder::new_current_thread()
607                .enable_all()
608                .build()
609                .unwrap();
610            rt.block_on(llm_chat(&llm, &sys, &user_prompt))
611        })
612        .join()
613        .unwrap();
614
615        response.map_or_else(
616            || StepResult {
617                name: "[assert] custom".into(),
618                status: StepStatus::Failed,
619                message: "LLM assertion call failed (server down?)".into(),
620            },
621            |content| {
622                let content_lower = content.to_lowercase().trim().to_owned();
623                if content_lower.starts_with("pass") {
624                    StepResult {
625                        name: "[assert] custom".into(),
626                        status: StepStatus::Passed,
627                        message: "PASS".into(),
628                    }
629                } else {
630                    StepResult {
631                        name: "[assert] custom".into(),
632                        status: StepStatus::Failed,
633                        message: content,
634                    }
635                }
636            },
637        )
638    }
639
640    fn run_screenshot(path: Option<&str>, tab: &Tab) -> StepResult {
641        let path = path.unwrap_or("screenshot.png");
642
643        match tab.capture_screenshot(
644            headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption::Png,
645            None,
646            None,
647            true,
648        ) {
649            Ok(data) => {
650                if let Err(e) = std::fs::write(path, &data) {
651                    return StepResult {
652                        name: format!("[screenshot] {path}"),
653                        status: StepStatus::Failed,
654                        message: format!("failed to write screenshot: {e}"),
655                    };
656                }
657                StepResult {
658                    name: format!("[screenshot] {path}"),
659                    status: StepStatus::Passed,
660                    message: format!("saved to {path}"),
661                }
662            }
663            Err(e) => StepResult {
664                name: format!("[screenshot] {path}"),
665                status: StepStatus::Failed,
666                message: format!("screenshot failed: {e}"),
667            },
668        }
669    }
670
671    // ── helpers ──────────────────────────────────────────────────────────
672
673    /// Resolves a CSS selector for the target element. Uses the explicit
674    /// `selector` if provided, otherwise asks the LLM to find the element
675    /// from the natural language `target` description and page DOM.
676    fn resolve_selector(
677        &self,
678        css_override: Option<&str>,
679        target: &str,
680        tab: &Tab,
681    ) -> Result<String, String> {
682        if let Some(explicit) = css_override {
683            return Ok(explicit.to_owned());
684        }
685
686        let dom_info = extract_dom_info(tab)?;
687        let page_content = get_page_text(tab);
688
689        let system = concat!(
690            "You are a browser automation selector generator. ",
691            "Given a web page's content and interactive elements, ",
692            "return ONLY the best CSS selector for the described element. ",
693            "Output nothing except the CSS selector. ",
694            "Prefer selectors in this order: #id, [data-testid=\"...\"], ",
695            "[name=\"...\"], tag.class, tag. ",
696            "Never output explanations, markdown, or extra text."
697        );
698
699        let user = format!(
700            "Page URL: {}\nPage Title: {}\n\nPage body text (first 4000 chars):\n{}\n\nInteractive elements:\n{}\n\nFind the CSS selector for: {}",
701            page_content.url,
702            page_content.title,
703            truncate(&page_content.body_text, 4000),
704            dom_info,
705            target,
706        );
707
708        eprintln!("      LLM targeting: {target}");
709
710        let (llm, sys, user_prompt) = (self.llm.clone(), system.to_owned(), user);
711        let selector = std::thread::spawn(move || {
712            let rt = tokio::runtime::Builder::new_current_thread()
713                .enable_all()
714                .build()
715                .unwrap();
716            rt.block_on(llm_chat(&llm, &sys, &user_prompt))
717        })
718        .join()
719        .unwrap()
720        .ok_or_else(|| "LLM element targeting failed (server down?)".to_owned())?;
721
722        let clean = selector
723            .trim()
724            .trim_matches('"')
725            .trim_matches('\'')
726            .trim_matches('`')
727            .to_owned();
728        eprintln!("      resolved selector: {clean}");
729
730        Ok(clean)
731    }
732}
733
734// ── Free helper functions ──────────────────────────────────────────────
735
736fn run_navigate_step(full_url: &str, tab: &Tab) -> StepResult {
737    let name = format!("[navigate] {full_url}");
738    match tab.navigate_to(full_url) {
739        Ok(_) => {
740            let _ = tab.wait_until_navigated();
741            StepResult {
742                name,
743                status: StepStatus::Passed,
744                message: format!("navigated to {full_url}"),
745            }
746        }
747        Err(e) => StepResult {
748            name,
749            status: StepStatus::Failed,
750            message: format!("navigation failed: {e}"),
751        },
752    }
753}
754
755fn extract_dom_info(tab: &Tab) -> Result<String, String> {
756    let result = tab
757        .evaluate(DOM_EXTRACT_JS, false)
758        .map_err(|e| format!("DOM extraction failed: {e}"))?;
759
760    let json_str = result
761        .value
762        .as_ref()
763        .and_then(|v| v.as_str())
764        .unwrap_or("[]");
765
766    let elements: Vec<String> = serde_json::from_str(json_str).unwrap_or_default();
767
768    if elements.is_empty() {
769        return Ok("(no interactive elements found)".to_owned());
770    }
771
772    Ok(elements.join("\n"))
773}
774
775fn get_page_text(tab: &Tab) -> PageContent {
776    let url = tab.get_url();
777
778    let title = tab
779        .evaluate("document.title", false)
780        .ok()
781        .and_then(|r| r.value)
782        .and_then(|v| v.as_str().map(String::from))
783        .unwrap_or_else(|| "unknown".to_owned());
784
785    let body_text = tab
786        .evaluate("document.body ? document.body.innerText : ''", false)
787        .ok()
788        .and_then(|r| r.value)
789        .and_then(|v| v.as_str().map(String::from))
790        .unwrap_or_default();
791
792    PageContent {
793        url,
794        title,
795        body_text: truncate(&body_text, 8000),
796    }
797}
798
799fn resolve_url(url: &str, base_url: &str) -> String {
800    if url.starts_with("http://") || url.starts_with("https://") {
801        return url.to_owned();
802    }
803    let base = base_url.trim_end_matches('/');
804    if url.starts_with('/') {
805        format!("{base}{url}")
806    } else {
807        format!("{base}/{url}")
808    }
809}
810
811// ── Support types ──────────────────────────────────────────────────────
812
813struct TestRunResult {
814    passed: u32,
815    failed: u32,
816    skipped: u32,
817    total: u32,
818    details: Vec<StepResult>,
819}
820
821struct PageContent {
822    url: String,
823    title: String,
824    body_text: String,
825}