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