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