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