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.llm_api_key.clone().or_else(|| {
105 std::env::var("HARNESS_LLM_API_KEY").ok()
106 }),
107 headers: if scenario_config.llm_headers.is_empty() {
108 crate::parse_headers_env()
109 } else {
110 scenario_config.llm_headers.clone()
111 },
112 timeout: Duration::from_secs(scenario_config.timeout_secs.unwrap_or(60)),
113 temperature: scenario_config.temperature,
114 thinking: scenario_config.thinking,
115 };
116 let timeout = Duration::from_secs(scenario_config.timeout_secs.unwrap_or(60));
117 let viewport_width = scenario_config.viewport_width.unwrap_or(1280);
118 let viewport_height = scenario_config.viewport_height.unwrap_or(720);
119 let defs_map: HashMap<String, AssertDefinition> = definitions
120 .into_iter()
121 .map(|d| (d.name.clone(), d))
122 .collect();
123
124 Self {
125 config: scenario_config,
126 definitions: defs_map,
127 llm,
128 timeout,
129 viewport_width,
130 viewport_height,
131 }
132 }
133
134 pub fn run(&self, tests: &[TestGroup]) -> anyhow::Result<RunReport> {
140 let mut report = RunReport::default();
141
142 if tests.is_empty() {
143 eprintln!("No tests defined in scenario.");
144 return Ok(report);
145 }
146
147 let browser_headless = self.config.browser_headless.unwrap_or(true);
148
149 let launch_opts = LaunchOptions {
150 headless: browser_headless,
151 window_size: Some((self.viewport_width, self.viewport_height)),
152 sandbox: false,
153 ..LaunchOptions::default()
154 };
155
156 let browser = Browser::new(launch_opts).context("failed to launch browser")?;
157 let tab = browser.new_tab().context("failed to open browser tab")?;
158 let _ = tab.set_default_timeout(self.timeout);
159
160 for test in tests {
161 eprintln!("\n══════════════════════════════");
162 eprintln!(" TEST: {}", test.name);
163 eprintln!("══════════════════════════════");
164
165 let test_result = self.run_test(test, &tab);
166 if test_result.failed == 0 && test_result.total > 0 {
167 report.tests_passed += 1;
168 eprintln!(" TEST ✅ PASSED");
169 } else if test_result.total > 0 {
170 report.tests_failed += 1;
171 eprintln!(" TEST ❌ FAILED");
172 }
173
174 report.passed += test_result.passed;
175 report.failed += test_result.failed;
176 report.skipped += test_result.skipped;
177 report.details.extend(test_result.details);
178 }
179
180 Ok(report)
181 }
182
183 fn run_test(&self, test: &TestGroup, tab: &Tab) -> TestRunResult {
184 let base_url = test
185 .base_url
186 .clone()
187 .or_else(|| self.config.base_url.clone())
188 .unwrap_or_else(crate::base_url);
189
190 let auto_navigate = test.auto_navigate.unwrap_or(self.config.auto_navigate);
191
192 let start_url = test
193 .start_url
194 .clone()
195 .or_else(|| self.config.start_url.clone())
196 .unwrap_or_else(|| "/dashboard".to_owned());
197
198 if auto_navigate {
199 let full_url = resolve_url(&start_url, &base_url);
200 eprintln!(" → auto-navigate: {full_url}");
201 let _ = tab.navigate_to(&full_url);
202 let _ = tab.wait_until_navigated();
203 std::thread::sleep(Duration::from_secs(4));
204 }
205
206 let mut result = TestRunResult {
207 passed: 0,
208 failed: 0,
209 skipped: 0,
210 total: 0,
211 details: Vec::new(),
212 };
213
214 for step in &test.steps {
215 result.total += 1;
216
217 let wait_ms = match step {
219 TestStep::Navigate { wait_after_ms, .. }
220 | TestStep::Click { wait_after_ms, .. }
221 | TestStep::Type { wait_after_ms, .. } => *wait_after_ms,
222 _ => None,
223 };
224
225 let step_result = match step {
226 TestStep::Navigate { url, .. } => {
227 let full_url = resolve_url(url, &base_url);
228 run_navigate_step(&full_url, tab)
229 }
230 TestStep::Click {
231 target, selector, ..
232 } => self.run_click(target, selector.as_deref(), tab),
233 TestStep::Type {
234 target,
235 text,
236 selector,
237 ..
238 } => self.run_type(target, text, selector.as_deref(), tab),
239 TestStep::Wait {
240 target,
241 selector,
242 timeout_ms,
243 } => self.run_wait(target, selector.as_deref(), *timeout_ms, tab),
244 TestStep::Assert {
245 definition,
246 preset,
247 prompt,
248 assert_text,
249 } => self.run_assert(
250 definition.as_deref(),
251 preset.as_deref(),
252 prompt.as_deref(),
253 assert_text.as_deref(),
254 tab,
255 ),
256 TestStep::Screenshot { path } => Self::run_screenshot(path.as_deref(), tab),
257 };
258
259 eprintln!(
260 " {} {} — {}",
261 if step_result.status == StepStatus::Passed {
262 "✅"
263 } else if step_result.status == StepStatus::Failed {
264 "❌"
265 } else {
266 "⏭️"
267 },
268 step_result.name,
269 step_result.message,
270 );
271
272 match step_result.status {
273 StepStatus::Passed => result.passed += 1,
274 StepStatus::Failed => result.failed += 1,
275 StepStatus::Skipped => result.skipped += 1,
276 }
277
278 if let Some(ms) = wait_ms {
279 std::thread::sleep(Duration::from_millis(ms));
280 }
281
282 result.details.push(step_result);
283 }
284
285 result
286 }
287
288 fn run_click(&self, target: &str, selector_override: Option<&str>, tab: &Tab) -> StepResult {
291 let selector = match self.resolve_selector(selector_override, target, tab) {
292 Ok(s) => s,
293 Err(msg) => {
294 return StepResult {
295 name: format!("[click] {target}"),
296 status: StepStatus::Failed,
297 message: msg,
298 };
299 }
300 };
301
302 match tab.wait_for_element(&selector) {
303 Ok(element) => match element.click() {
304 Ok(_) => StepResult {
305 name: format!("[click] {target}"),
306 status: StepStatus::Passed,
307 message: format!("clicked {selector}"),
308 },
309 Err(e) => StepResult {
310 name: format!("[click] {target}"),
311 status: StepStatus::Failed,
312 message: format!("click failed on {selector}: {e}"),
313 },
314 },
315 Err(e) => StepResult {
316 name: format!("[click] {target}"),
317 status: StepStatus::Failed,
318 message: format!("element {selector} not found: {e}"),
319 },
320 }
321 }
322
323 fn run_type(
324 &self,
325 target: &str,
326 text: &str,
327 selector_override: Option<&str>,
328 tab: &Tab,
329 ) -> StepResult {
330 let selector = match self.resolve_selector(selector_override, target, tab) {
331 Ok(s) => s,
332 Err(msg) => {
333 return StepResult {
334 name: format!("[type] {target}"),
335 status: StepStatus::Failed,
336 message: msg,
337 };
338 }
339 };
340
341 match tab.wait_for_element(&selector) {
342 Ok(element) => {
343 if let Err(e) = element.click() {
344 return StepResult {
345 name: format!("[type] {target}"),
346 status: StepStatus::Failed,
347 message: format!("click to focus {selector} failed: {e}"),
348 };
349 }
350
351 let js = format!(
352 "document.querySelector('{}').value = '';",
353 selector.replace('\'', "\\'")
354 );
355 let _ = tab.evaluate(&js, false);
356
357 match element.type_into(text) {
358 Ok(_) => StepResult {
359 name: format!("[type] {target}"),
360 status: StepStatus::Passed,
361 message: format!("typed {text:?} into {selector}"),
362 },
363 Err(e) => StepResult {
364 name: format!("[type] {target}"),
365 status: StepStatus::Failed,
366 message: format!("type into {selector} failed: {e}"),
367 },
368 }
369 }
370 Err(e) => StepResult {
371 name: format!("[type] {target}"),
372 status: StepStatus::Failed,
373 message: format!("element {selector} not found: {e}"),
374 },
375 }
376 }
377
378 fn run_wait(
379 &self,
380 target: &str,
381 selector_override: Option<&str>,
382 timeout_ms: Option<u64>,
383 tab: &Tab,
384 ) -> StepResult {
385 let selector = match self.resolve_selector(selector_override, target, tab) {
386 Ok(s) => s,
387 Err(msg) => {
388 return StepResult {
389 name: format!("[wait] {target}"),
390 status: StepStatus::Failed,
391 message: msg,
392 };
393 }
394 };
395
396 let timeout = Duration::from_millis(timeout_ms.unwrap_or(10_000));
397
398 match tab.wait_for_element_with_custom_timeout(&selector, timeout) {
399 Ok(_) => StepResult {
400 name: format!("[wait] {target}"),
401 status: StepStatus::Passed,
402 message: format!("found {selector}"),
403 },
404 Err(e) => StepResult {
405 name: format!("[wait] {target}"),
406 status: StepStatus::Failed,
407 message: format!("wait for {selector} timed out: {e}"),
408 },
409 }
410 }
411
412 fn run_assert(
413 &self,
414 definition: Option<&str>,
415 preset: Option<&str>,
416 prompt: Option<&str>,
417 assert_text: Option<&str>,
418 tab: &Tab,
419 ) -> StepResult {
420 std::thread::sleep(Duration::from_millis(500));
421
422 let page_content = get_page_text(tab);
423
424 if let Some(def_name) = definition {
425 if let Some(def) = self.definitions.get(def_name) {
426 return self.run_assert_def(def, &page_content);
427 }
428 return StepResult {
429 name: format!("[assert] {def_name}"),
430 status: StepStatus::Failed,
431 message: format!("definition '{def_name}' not found"),
432 };
433 }
434
435 if let Some(preset_name) = preset {
436 return self.run_preset(preset_name, assert_text, &page_content);
437 }
438
439 if let Some(prompt_text) = prompt {
440 return self.run_custom(prompt_text, &page_content);
441 }
442
443 StepResult {
444 name: "[assert]".into(),
445 status: StepStatus::Skipped,
446 message: "no definition, preset, or prompt specified".into(),
447 }
448 }
449
450 fn run_assert_def(&self, def: &AssertDefinition, page_content: &PageContent) -> StepResult {
451 def.preset.as_ref().map_or_else(
452 || {
453 def.prompt.as_ref().map_or_else(
454 || StepResult {
455 name: format!("[assert] {}", def.name),
456 status: StepStatus::Failed,
457 message: "definition has no preset or prompt".into(),
458 },
459 |prompt| self.run_custom(prompt, page_content),
460 )
461 },
462 |preset_name| self.run_preset(preset_name, def.assert_text.as_deref(), page_content),
463 )
464 }
465
466 fn run_preset(
467 &self,
468 preset_name: &str,
469 assert_text: Option<&str>,
470 page_content: &PageContent,
471 ) -> StepResult {
472 let Some(preset) = ASSERTION_PRESETS.iter().find(|p| p.name == preset_name) else {
473 return StepResult {
474 name: format!("[assert] {preset_name}"),
475 status: StepStatus::Failed,
476 message: format!("unknown assertion preset: {preset_name}"),
477 };
478 };
479
480 let user_prompt = preset
481 .user_template
482 .replace("{url}", &page_content.url)
483 .replace("{title}", &page_content.title)
484 .replace("{content}", &page_content.body_text)
485 .replace("{expected_text}", assert_text.unwrap_or(""))
486 .replace("{description}", "");
487
488 eprintln!(" assert: {preset_name}");
489
490 let (llm, sys, prompt) = (
491 self.llm.clone(),
492 preset.system.to_owned(),
493 user_prompt,
494 );
495 let response = std::thread::spawn(move || {
496 let rt = tokio::runtime::Builder::new_current_thread()
497 .enable_all()
498 .build()
499 .unwrap();
500 rt.block_on(llm_chat(&llm, &sys, &prompt))
501 })
502 .join()
503 .unwrap();
504
505 response.map_or_else(
506 || StepResult {
507 name: format!("[assert] {preset_name}"),
508 status: StepStatus::Failed,
509 message: "LLM assertion call failed (server down?)".into(),
510 },
511 |content| {
512 let content_lower = content.to_lowercase().trim().to_owned();
513 if content_lower.starts_with("pass") {
514 StepResult {
515 name: format!("[assert] {preset_name}"),
516 status: StepStatus::Passed,
517 message: "PASS".into(),
518 }
519 } else {
520 StepResult {
521 name: format!("[assert] {preset_name}"),
522 status: StepStatus::Failed,
523 message: content,
524 }
525 }
526 },
527 )
528 }
529
530 fn run_custom(&self, prompt: &str, page_content: &PageContent) -> StepResult {
531 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.";
532
533 let user = format!(
534 "Page URL: {url}\nPage Title: {title}\n\nPage Content:\n{content}\n\nAssertion: {prompt}",
535 url = page_content.url,
536 title = page_content.title,
537 content = page_content.body_text,
538 );
539
540 eprintln!(" custom assert");
541
542 let (llm, sys, user_prompt) = (
543 self.llm.clone(),
544 system.to_owned(),
545 user,
546 );
547 let response = std::thread::spawn(move || {
548 let rt = tokio::runtime::Builder::new_current_thread()
549 .enable_all()
550 .build()
551 .unwrap();
552 rt.block_on(llm_chat(&llm, &sys, &user_prompt))
553 })
554 .join()
555 .unwrap();
556
557 response.map_or_else(
558 || StepResult {
559 name: "[assert] custom".into(),
560 status: StepStatus::Failed,
561 message: "LLM assertion call failed (server down?)".into(),
562 },
563 |content| {
564 let content_lower = content.to_lowercase().trim().to_owned();
565 if content_lower.starts_with("pass") {
566 StepResult {
567 name: "[assert] custom".into(),
568 status: StepStatus::Passed,
569 message: "PASS".into(),
570 }
571 } else {
572 StepResult {
573 name: "[assert] custom".into(),
574 status: StepStatus::Failed,
575 message: content,
576 }
577 }
578 },
579 )
580 }
581
582 fn run_screenshot(path: Option<&str>, tab: &Tab) -> StepResult {
583 let path = path.unwrap_or("screenshot.png");
584
585 match tab.capture_screenshot(
586 headless_chrome::protocol::cdp::Page::CaptureScreenshotFormatOption::Png,
587 None,
588 None,
589 true,
590 ) {
591 Ok(data) => {
592 if let Err(e) = std::fs::write(path, &data) {
593 return StepResult {
594 name: format!("[screenshot] {path}"),
595 status: StepStatus::Failed,
596 message: format!("failed to write screenshot: {e}"),
597 };
598 }
599 StepResult {
600 name: format!("[screenshot] {path}"),
601 status: StepStatus::Passed,
602 message: format!("saved to {path}"),
603 }
604 }
605 Err(e) => StepResult {
606 name: format!("[screenshot] {path}"),
607 status: StepStatus::Failed,
608 message: format!("screenshot failed: {e}"),
609 },
610 }
611 }
612
613 fn resolve_selector(
619 &self,
620 css_override: Option<&str>,
621 target: &str,
622 tab: &Tab,
623 ) -> Result<String, String> {
624 if let Some(explicit) = css_override {
625 return Ok(explicit.to_owned());
626 }
627
628 let dom_info = extract_dom_info(tab)?;
629 let page_content = get_page_text(tab);
630
631 let system = concat!(
632 "You are a browser automation selector generator. ",
633 "Given a web page's content and interactive elements, ",
634 "return ONLY the best CSS selector for the described element. ",
635 "Output nothing except the CSS selector. ",
636 "Prefer selectors in this order: #id, [data-testid=\"...\"], ",
637 "[name=\"...\"], tag.class, tag. ",
638 "Never output explanations, markdown, or extra text."
639 );
640
641 let user = format!(
642 "Page URL: {}\nPage Title: {}\n\nPage body text (first 4000 chars):\n{}\n\nInteractive elements:\n{}\n\nFind the CSS selector for: {}",
643 page_content.url,
644 page_content.title,
645 truncate(&page_content.body_text, 4000),
646 dom_info,
647 target,
648 );
649
650 eprintln!(" LLM targeting: {target}");
651
652 let (llm, sys, user_prompt) = (
653 self.llm.clone(),
654 system.to_owned(),
655 user,
656 );
657 let selector = std::thread::spawn(move || {
658 let rt = tokio::runtime::Builder::new_current_thread()
659 .enable_all()
660 .build()
661 .unwrap();
662 rt.block_on(llm_chat(&llm, &sys, &user_prompt))
663 })
664 .join()
665 .unwrap()
666 .ok_or_else(|| "LLM element targeting failed (server down?)".to_owned())?;
667
668 let clean = selector
669 .trim()
670 .trim_matches('"')
671 .trim_matches('\'')
672 .trim_matches('`')
673 .to_owned();
674 eprintln!(" resolved selector: {clean}");
675
676 Ok(clean)
677 }
678}
679
680fn run_navigate_step(full_url: &str, tab: &Tab) -> StepResult {
683 let name = format!("[navigate] {full_url}");
684 match tab.navigate_to(full_url) {
685 Ok(_) => {
686 let _ = tab.wait_until_navigated();
687 StepResult {
688 name,
689 status: StepStatus::Passed,
690 message: format!("navigated to {full_url}"),
691 }
692 }
693 Err(e) => StepResult {
694 name,
695 status: StepStatus::Failed,
696 message: format!("navigation failed: {e}"),
697 },
698 }
699}
700
701fn extract_dom_info(tab: &Tab) -> Result<String, String> {
702 let result = tab
703 .evaluate(DOM_EXTRACT_JS, false)
704 .map_err(|e| format!("DOM extraction failed: {e}"))?;
705
706 let json_str = result
707 .value
708 .as_ref()
709 .and_then(|v| v.as_str())
710 .unwrap_or("[]");
711
712 let elements: Vec<String> = serde_json::from_str(json_str).unwrap_or_default();
713
714 if elements.is_empty() {
715 return Ok("(no interactive elements found)".to_owned());
716 }
717
718 Ok(elements.join("\n"))
719}
720
721fn get_page_text(tab: &Tab) -> PageContent {
722 let url = tab.get_url();
723
724 let title = tab
725 .evaluate("document.title", false)
726 .ok()
727 .and_then(|r| r.value)
728 .and_then(|v| v.as_str().map(String::from))
729 .unwrap_or_else(|| "unknown".to_owned());
730
731 let body_text = tab
732 .evaluate("document.body ? document.body.innerText : ''", false)
733 .ok()
734 .and_then(|r| r.value)
735 .and_then(|v| v.as_str().map(String::from))
736 .unwrap_or_default();
737
738 PageContent {
739 url,
740 title,
741 body_text: truncate(&body_text, 8000),
742 }
743}
744
745fn resolve_url(url: &str, base_url: &str) -> String {
746 if url.starts_with("http://") || url.starts_with("https://") {
747 return url.to_owned();
748 }
749 let base = base_url.trim_end_matches('/');
750 if url.starts_with('/') {
751 format!("{base}{url}")
752 } else {
753 format!("{base}/{url}")
754 }
755}
756
757struct TestRunResult {
760 passed: u32,
761 failed: u32,
762 skipped: u32,
763 total: u32,
764 details: Vec<StepResult>,
765}
766
767struct PageContent {
768 url: String,
769 title: String,
770 body_text: String,
771}