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
22const BROWSER_IDLE_TIMEOUT: Duration = Duration::from_secs(6 * 60 * 60);
28
29pub 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 artifacts_dir: PathBuf,
43}
44
45#[derive(Debug, Default)]
47pub struct RunReport {
48 pub tests_passed: u32,
50 pub tests_failed: u32,
52 pub passed: u32,
54 pub failed: u32,
56 pub skipped: u32,
58 pub details: Vec<StepResult>,
60}
61
62#[derive(Debug)]
64pub struct StepResult {
65 pub name: String,
67 pub status: StepStatus,
69 pub message: String,
71}
72
73#[derive(Debug, PartialEq, Eq)]
75pub enum StepStatus {
76 Passed,
78 Failed,
80 Skipped,
82}
83
84struct AssertPreset {
86 name: &'static str,
87 system: &'static str,
88 user_template: &'static str,
89}
90
91#[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 #[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 #[must_use]
168 pub fn usage_tracker(&self) -> Arc<UsageTracker> {
169 Arc::clone(&self.usage)
170 }
171
172 #[must_use]
174 pub const fn budget_tracker(&self) -> &BudgetTracker {
175 &self.budgets
176 }
177
178 #[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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 #[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 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
1429fn 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
1438fn selector_matches(tab: &Tab, selector: &str) -> Result<bool, String> {
1440 eval_bool(tab, &crate::selectors::selector_matches_js(selector))
1441}
1442
1443fn 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
1523fn 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#[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#[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}