1use anyhow::{bail, Context, Result};
20use serde::{Deserialize, Serialize};
21use std::fmt;
22use std::path::{Path, PathBuf};
23use std::process::Output;
24use tokio::process::Command;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "kebab-case")]
31pub enum Browser {
32 Chromium,
33 Firefox,
34 WebKit,
35}
36
37impl Default for Browser {
38 fn default() -> Self {
39 Browser::Chromium
40 }
41}
42
43impl fmt::Display for Browser {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 match self {
46 Browser::Chromium => write!(f, "chromium"),
47 Browser::Firefox => write!(f, "firefox"),
48 Browser::WebKit => write!(f, "webkit"),
49 }
50 }
51}
52
53impl std::str::FromStr for Browser {
54 type Err = String;
55
56 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
57 match s.to_lowercase().as_str() {
58 "chromium" | "chrome" => Ok(Browser::Chromium),
59 "firefox" => Ok(Browser::Firefox),
60 "webkit" | "safari" => Ok(Browser::WebKit),
61 other => Err(format!(
62 "Unknown browser '{}'. Supported: chromium, firefox, webkit",
63 other
64 )),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PlaywrightConfig {
72 #[serde(default)]
74 pub browser: Browser,
75
76 #[serde(default = "default_true")]
78 pub headless: bool,
79
80 pub base_url: Option<String>,
82
83 pub working_dir: Option<PathBuf>,
85
86 #[serde(default = "default_timeout")]
88 pub timeout_ms: u64,
89
90 #[serde(default)]
92 pub extra_args: Vec<String>,
93
94 pub config_file: Option<PathBuf>,
96}
97
98fn default_true() -> bool {
99 true
100}
101
102fn default_timeout() -> u64 {
103 30_000
104}
105
106impl Default for PlaywrightConfig {
107 fn default() -> Self {
108 Self {
109 browser: Browser::Chromium,
110 headless: true,
111 base_url: None,
112 working_dir: None,
113 timeout_ms: default_timeout(),
114 extra_args: Vec::new(),
115 config_file: None,
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(tag = "action", rename_all = "snake_case")]
125pub enum BrowserAction {
126 Navigate {
128 url: String,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 wait_until: Option<WaitUntil>,
132 },
133
134 Click {
136 selector: String,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 button: Option<MouseButton>,
139 },
140
141 Fill {
143 selector: String,
144 value: String,
145 },
146
147 Press {
149 key: String,
150 },
151
152 Select {
154 selector: String,
155 values: Vec<String>,
156 },
157
158 Check {
160 selector: String,
161 },
162
163 Uncheck {
165 selector: String,
166 },
167
168 Hover {
170 selector: String,
171 },
172
173 WaitForSelector {
175 selector: String,
176 #[serde(skip_serializing_if = "Option::is_none")]
177 state: Option<WaitState>,
178 },
179
180 WaitForNavigation {
182 #[serde(skip_serializing_if = "Option::is_none")]
183 url: Option<String>,
184 },
185
186 Screenshot {
188 path: String,
190 #[serde(default)]
192 full_page: bool,
193 },
194
195 GetText {
197 selector: String,
198 },
199
200 Evaluate {
202 expression: String,
203 },
204
205 Upload {
207 selector: String,
208 files: Vec<String>,
209 },
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
214#[serde(rename_all = "kebab-case")]
215pub enum WaitUntil {
216 Load,
217 DomContentLoaded,
218 NetworkIdle,
219 Commit,
220}
221
222impl fmt::Display for WaitUntil {
223 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224 match self {
225 WaitUntil::Load => write!(f, "load"),
226 WaitUntil::DomContentLoaded => write!(f, "domcontentloaded"),
227 WaitUntil::NetworkIdle => write!(f, "networkidle"),
228 WaitUntil::Commit => write!(f, "commit"),
229 }
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "kebab-case")]
236pub enum WaitState {
237 Attached,
238 Detached,
239 Visible,
240 Hidden,
241}
242
243impl fmt::Display for WaitState {
244 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245 match self {
246 WaitState::Attached => write!(f, "attached"),
247 WaitState::Detached => write!(f, "detached"),
248 WaitState::Visible => write!(f, "visible"),
249 WaitState::Hidden => write!(f, "hidden"),
250 }
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "kebab-case")]
257pub enum MouseButton {
258 Left,
259 Right,
260 Middle,
261}
262
263impl fmt::Display for MouseButton {
264 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265 match self {
266 MouseButton::Left => write!(f, "left"),
267 MouseButton::Right => write!(f, "right"),
268 MouseButton::Middle => write!(f, "middle"),
269 }
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct TestConfig {
278 #[serde(default = "default_test_paths")]
280 pub test_paths: Vec<String>,
281
282 pub project: Option<String>,
284
285 #[serde(default)]
287 pub repeat_each: u32,
288
289 #[serde(default)]
291 pub retries: u32,
292
293 #[serde(default = "default_workers")]
295 pub workers: u32,
296
297 #[serde(default = "default_timeout")]
299 pub timeout_ms: u64,
300
301 #[serde(default)]
303 pub update_snapshots: bool,
304
305 pub grep: Option<String>,
307
308 #[serde(default)]
310 pub reporter: TestReporter,
311
312 pub output_dir: Option<PathBuf>,
314
315 pub working_dir: Option<PathBuf>,
317}
318
319fn default_workers() -> u32 {
320 1
321}
322
323fn default_test_paths() -> Vec<String> {
324 vec![".".to_string()]
325}
326
327impl Default for TestConfig {
328 fn default() -> Self {
329 Self {
330 test_paths: default_test_paths(),
331 project: None,
332 repeat_each: 0,
333 retries: 0,
334 workers: default_workers(),
335 timeout_ms: default_timeout(),
336 update_snapshots: false,
337 grep: None,
338 reporter: TestReporter::List,
339 output_dir: None,
340 working_dir: None,
341 }
342 }
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
347#[serde(rename_all = "kebab-case")]
348pub enum TestReporter {
349 List,
350 Line,
351 Dot,
352 Html,
353 Json,
354 Junit,
355}
356
357impl Default for TestReporter {
358 fn default() -> Self {
359 TestReporter::List
360 }
361}
362
363impl fmt::Display for TestReporter {
364 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
365 match self {
366 TestReporter::List => write!(f, "list"),
367 TestReporter::Line => write!(f, "line"),
368 TestReporter::Dot => write!(f, "dot"),
369 TestReporter::Html => write!(f, "html"),
370 TestReporter::Json => write!(f, "json"),
371 TestReporter::Junit => write!(f, "junit"),
372 }
373 }
374}
375
376#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct TestResult {
379 pub success: bool,
381 pub passed: u32,
383 pub failed: u32,
385 pub skipped: u32,
387 pub timed_out: u32,
389 pub duration_ms: u64,
391 pub stdout: String,
393 pub stderr: String,
395 pub exit_code: i32,
397}
398
399impl TestResult {
400 pub fn total(&self) -> u32 {
402 self.passed + self.failed + self.skipped + self.timed_out
403 }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct ScreenshotResult {
411 pub path: PathBuf,
413 pub size_bytes: u64,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct CommandOutput {
422 pub stdout: String,
424 pub stderr: String,
426 pub success: bool,
428 pub exit_code: i32,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct ActionResults {
437 pub success: bool,
439 pub actions_total: usize,
441 pub results: Vec<ActionResult>,
443 pub stdout: String,
445 pub stderr: String,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct ActionResult {
452 pub action: String,
454 pub success: bool,
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub output: Option<String>,
459 #[serde(skip_serializing_if = "Option::is_none")]
461 pub error: Option<String>,
462}
463
464impl ActionResult {
465 pub fn output(text: &str) -> Self {
467 Self {
468 action: "unknown".to_string(),
469 success: true,
470 output: Some(text.to_string()),
471 error: None,
472 }
473 }
474}
475
476pub struct PlaywrightCli {
505 config: PlaywrightConfig,
506}
507
508impl PlaywrightCli {
509 pub fn new(config: PlaywrightConfig) -> Self {
511 Self { config }
512 }
513
514 pub fn with_browser(browser: Browser) -> Self {
516 Self {
517 config: PlaywrightConfig {
518 browser,
519 ..Default::default()
520 },
521 }
522 }
523
524 pub fn config(&self) -> &PlaywrightConfig {
526 &self.config
527 }
528
529 fn working_dir(&self) -> &Path {
531 self.config
532 .working_dir
533 .as_deref()
534 .unwrap_or_else(|| Path::new("."))
535 }
536
537 pub async fn check_installed(&self) -> Result<bool> {
543 let result = self.run_npx(&["--version"]).await;
544 match result {
545 Ok(output) => {
546 let stdout = String::from_utf8_lossy(&output.stdout);
547 tracing::debug!("Playwright version: {}", stdout.trim());
548 Ok(true)
549 }
550 Err(e) => {
551 tracing::debug!("Playwright not available: {}", e);
552 Ok(false)
553 }
554 }
555 }
556
557 pub async fn ensure_installed(&self) -> Result<()> {
563 if self.check_installed().await? {
564 tracing::info!("Playwright is already installed");
565 return Ok(());
566 }
567
568 tracing::info!("Installing @playwright/test...");
569
570 let output = Command::new("npm")
572 .args(["install", "--save-dev", "@playwright/test"])
573 .current_dir(self.working_dir())
574 .output()
575 .await
576 .context("Failed to run npm install")?;
577
578 if !output.status.success() {
579 let stderr = String::from_utf8_lossy(&output.stderr);
580 bail!("npm install failed: {}", stderr);
581 }
582
583 tracing::info!("Installing Playwright browsers...");
585 let output = self.run_npx(&["install"]).await?;
586
587 if !output.status.success() {
588 let stderr = String::from_utf8_lossy(&output.stderr);
589 bail!("Playwright browser install failed: {}", stderr);
590 }
591
592 tracing::info!("Playwright installed successfully");
593 Ok(())
594 }
595
596 pub async fn install_browser(&self, browser: Option<Browser>) -> Result<()> {
598 let mut args = vec!["install".to_string()];
599 if let Some(b) = browser {
600 args.push(b.to_string());
601 }
602
603 let output = self.run_npx(&args).await?;
604 if !output.status.success() {
605 let stderr = String::from_utf8_lossy(&output.stderr);
606 bail!("Browser install failed: {}", stderr);
607 }
608
609 Ok(())
610 }
611
612 pub async fn run_script(&self, script: &str) -> Result<CommandOutput> {
629 let tmp_dir = tempfile::tempdir().context("Failed to create temp dir")?;
631 let script_path = tmp_dir.path().join("playwright-script.js");
632 std::fs::write(&script_path, script)
633 .context("Failed to write temp script")?;
634
635 let node_output = Command::new("node")
636 .arg(&script_path)
637 .current_dir(self.working_dir())
638 .output()
639 .await
640 .context("Failed to run node script")?;
641
642 Ok(CommandOutput {
643 stdout: String::from_utf8_lossy(&node_output.stdout).to_string(),
644 stderr: String::from_utf8_lossy(&node_output.stderr).to_string(),
645 success: node_output.status.success(),
646 exit_code: node_output.status.code().unwrap_or(-1),
647 })
648 }
649
650 pub async fn execute_actions(
655 &self,
656 url: &str,
657 actions: &[BrowserAction],
658 ) -> Result<ActionResults> {
659 let script = Self::generate_action_script(
660 url,
661 actions,
662 self.config.headless,
663 self.config.timeout_ms,
664 );
665 let output = self.run_script(&script).await?;
666
667 let results = if output.success {
668 match serde_json::from_str::<ActionResults>(&output.stdout.trim()) {
670 Ok(r) => r,
671 Err(_) => ActionResults {
672 success: true,
673 actions_total: actions.len(),
674 results: vec![ActionResult::output(&output.stdout)],
675 stdout: output.stdout.clone(),
676 stderr: output.stderr.clone(),
677 },
678 }
679 } else {
680 ActionResults {
681 success: false,
682 actions_total: actions.len(),
683 results: vec![],
684 stdout: output.stdout,
685 stderr: output.stderr,
686 }
687 };
688
689 Ok(results)
690 }
691
692 pub async fn screenshot(
694 &self,
695 url: &str,
696 output_path: &str,
697 full_page: bool,
698 ) -> Result<ScreenshotResult> {
699 let full_page_flag = if full_page { "true" } else { "false" };
700 let script = format!(
701 r#"
702const {{ chromium }} = require('playwright');
703(async () => {{
704 const browser = await chromium.launch({{
705 headless: {headless}
706 }});
707 const page = await browser.newPage();
708 await page.goto('{url}', {{ waitUntil: 'networkidle', timeout: {timeout} }});
709 await page.screenshot({{
710 path: '{output_path}',
711 fullPage: {full_page_flag}
712 }});
713 await browser.close();
714
715 const fs = require('fs');
716 const stats = fs.statSync('{output_path}');
717 console.log(JSON.stringify({{ path: '{output_path}', size_bytes: stats.size }}));
718}})();
719"#,
720 headless = self.config.headless,
721 url = url,
722 timeout = self.config.timeout_ms,
723 output_path = output_path,
724 full_page_flag = full_page_flag,
725 );
726
727 let output = self.run_script(&script).await?;
728
729 if !output.success {
730 bail!("Screenshot failed: {}", output.stderr);
731 }
732
733 let result: serde_json::Value =
735 serde_json::from_str(output.stdout.trim()).unwrap_or_else(|_| {
736 serde_json::json!({
737 "path": output_path,
738 "size_bytes": 0
739 })
740 });
741
742 Ok(ScreenshotResult {
743 path: PathBuf::from(result["path"].as_str().unwrap_or(output_path)),
744 size_bytes: result["size_bytes"].as_u64().unwrap_or(0),
745 })
746 }
747
748 pub async fn get_text(&self, url: &str, selector: &str) -> Result<String> {
750 let script = format!(
751 r#"
752const {{ chromium }} = require('playwright');
753(async () => {{
754 const browser = await chromium.launch({{
755 headless: {headless}
756 }});
757 const page = await browser.newPage();
758 await page.goto('{url}', {{ waitUntil: 'networkidle', timeout: {timeout} }});
759 const text = await page.textContent('{selector}');
760 console.log(JSON.stringify({{ text: text || '' }}));
761 await browser.close();
762}})();
763"#,
764 headless = self.config.headless,
765 url = url,
766 timeout = self.config.timeout_ms,
767 selector = selector,
768 );
769
770 let output = self.run_script(&script).await?;
771
772 if !output.success {
773 bail!("Get text failed: {}", output.stderr);
774 }
775
776 let result: serde_json::Value = serde_json::from_str(output.stdout.trim())
777 .context("Failed to parse get_text output")?;
778
779 Ok(result["text"]
780 .as_str()
781 .unwrap_or_default()
782 .to_string())
783 }
784
785 pub async fn run_tests(&self, test_config: &TestConfig) -> Result<TestResult> {
789 let mut args = vec!["test".to_string()];
790
791 args.extend(test_config.test_paths.iter().cloned());
793
794 if let Some(ref project) = test_config.project {
796 args.push("--project".to_string());
797 args.push(project.clone());
798 }
799
800 args.push("--workers".to_string());
802 args.push(test_config.workers.to_string());
803
804 if test_config.retries > 0 {
806 args.push("--retries".to_string());
807 args.push(test_config.retries.to_string());
808 }
809
810 if test_config.repeat_each > 0 {
812 args.push("--repeat-each".to_string());
813 args.push(test_config.repeat_each.to_string());
814 }
815
816 args.push("--timeout".to_string());
818 args.push(test_config.timeout_ms.to_string());
819
820 args.push("--reporter".to_string());
822 args.push(test_config.reporter.to_string());
823
824 if test_config.update_snapshots {
826 args.push("--update-snapshots".to_string());
827 }
828
829 if let Some(ref grep) = test_config.grep {
831 args.push("--grep".to_string());
832 args.push(grep.clone());
833 }
834
835 if let Some(ref output_dir) = test_config.output_dir {
837 args.push("--output".to_string());
838 args.push(output_dir.to_string_lossy().to_string());
839 }
840
841 if let Some(ref config_file) = self.config.config_file {
843 args.push("--config".to_string());
844 args.push(config_file.to_string_lossy().to_string());
845 }
846
847 let working_dir = test_config
848 .working_dir
849 .as_deref()
850 .or(self.config.working_dir.as_deref())
851 .unwrap_or_else(|| Path::new("."));
852
853 let output = Command::new("npx")
854 .args(&["playwright"])
855 .args(&args)
856 .current_dir(working_dir)
857 .output()
858 .await
859 .context("Failed to run Playwright tests")?;
860
861 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
862 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
863 let exit_code = output.status.code().unwrap_or(-1);
864 let success = exit_code == 0;
865
866 let (passed, failed, skipped, timed_out, duration_ms) =
868 Self::parse_test_output(&stdout, &stderr);
869
870 Ok(TestResult {
871 success,
872 passed,
873 failed,
874 skipped,
875 timed_out,
876 duration_ms,
877 stdout,
878 stderr,
879 exit_code,
880 })
881 }
882
883 pub async fn run_test_file(&self, path: &str) -> Result<TestResult> {
885 let config = TestConfig {
886 test_paths: vec![path.to_string()],
887 ..Default::default()
888 };
889 self.run_tests(&config).await
890 }
891
892 pub fn generate_test_file(
897 test_name: &str,
898 url: &str,
899 actions: &[BrowserAction],
900 ) -> String {
901 let mut code = String::with_capacity(2048);
902
903 code.push_str("import { test, expect } from '@playwright/test';\n\n");
904
905 code.push_str(&format!(
906 "test('{}', async ({{ page }}) => {{\n",
907 test_name
908 ));
909
910 code.push_str(&format!(" await page.goto('{}');\n", url));
911
912 for action in actions {
913 code.push_str(&Self::action_to_playwright_code(action));
914 }
915
916 code.push_str("});\n");
917
918 code
919 }
920
921 fn generate_action_script(
925 url: &str,
926 actions: &[BrowserAction],
927 headless: bool,
928 timeout_ms: u64,
929 ) -> String {
930 let mut script = String::with_capacity(4096);
931
932 script.push_str("const { chromium } = require('playwright');\n");
933 script.push_str("(async () => {\n");
934 script.push_str(&format!(
935 " const browser = await chromium.launch({{ headless: {} }});\n",
936 headless
937 ));
938 script.push_str(" const page = await browser.newPage();\n");
939 script.push_str(" const results = [];\n\n try {\n");
940 script.push_str(&format!(
941 " await page.goto('{}', {{ waitUntil: 'networkidle', timeout: {} }});\n",
942 url, timeout_ms
943 ));
944
945 for action in actions {
946 script.push_str(&Self::action_to_node_code(action));
947 }
948
949 script.push_str(" console.log(JSON.stringify({ success: true, actions_total: ");
950 script.push_str(&actions.len().to_string());
951 script.push_str(", results }));\n");
952 script.push_str(" } catch (error) {\n");
953 script.push_str(" console.error(JSON.stringify({ success: false, error: error.message }));\n");
954 script.push_str(" } finally {\n");
955 script.push_str(" await browser.close();\n");
956 script.push_str(" }\n");
957 script.push_str("})();\n");
958
959 script
960 }
961
962 fn action_to_playwright_code(action: &BrowserAction) -> String {
964 match action {
965 BrowserAction::Navigate { url, wait_until } => {
966 let wait = wait_until
967 .map(|w| format!(", waitUntil: '{}'", w))
968 .unwrap_or_default();
969 format!(" await page.goto('{}'{});\n", url, wait)
970 }
971 BrowserAction::Click { selector, button } => {
972 let opts = button
973 .map(|b| format!(", {{ button: '{}' }}", b))
974 .unwrap_or_default();
975 format!(" await page.click('{}'{});\n", selector, opts)
976 }
977 BrowserAction::Fill { selector, value } => {
978 format!(" await page.fill('{}', '{}');\n", selector, value)
979 }
980 BrowserAction::Press { key } => {
981 format!(" await page.keyboard.press('{}');\n", key)
982 }
983 BrowserAction::Select { selector, values } => {
984 let vals: Vec<String> = values.iter().map(|v| format!("'{}'", v)).collect();
985 format!(" await page.selectOption('{}', [{}]);\n", selector, vals.join(", "))
986 }
987 BrowserAction::Check { selector } => {
988 format!(" await page.check('{}');\n", selector)
989 }
990 BrowserAction::Uncheck { selector } => {
991 format!(" await page.uncheck('{}');\n", selector)
992 }
993 BrowserAction::Hover { selector } => {
994 format!(" await page.hover('{}');\n", selector)
995 }
996 BrowserAction::WaitForSelector { selector, state } => {
997 let opts = state
998 .map(|s| format!(", {{ state: '{}' }}", s))
999 .unwrap_or_default();
1000 format!(" await page.waitForSelector('{}'{});\n", selector, opts)
1001 }
1002 BrowserAction::WaitForNavigation { url } => {
1003 let url_opt = url
1004 .as_ref()
1005 .map(|u| format!("{{ url: '{}' }}", u))
1006 .unwrap_or_default();
1007 format!(" await page.waitForNavigation({});\n", url_opt)
1008 }
1009 BrowserAction::Screenshot { path, full_page } => {
1010 format!(
1011 " await page.screenshot({{ path: '{}', fullPage: {} }});\n",
1012 path, full_page
1013 )
1014 }
1015 BrowserAction::GetText { selector } => {
1016 format!(
1017 " const text = await page.textContent('{}');\n console.log(text);\n",
1018 selector
1019 )
1020 }
1021 BrowserAction::Evaluate { expression } => {
1022 format!(" await page.evaluate(`{}`);\n", expression)
1023 }
1024 BrowserAction::Upload { selector, files } => {
1025 let files_json = serde_json::to_string(files).unwrap_or_default();
1026 format!(" await page.setInputFiles('{}', {});\n", selector, files_json)
1027 }
1028 }
1029 }
1030
1031 fn action_to_node_code(action: &BrowserAction) -> String {
1033 match action {
1034 BrowserAction::Navigate { url, wait_until } => {
1035 let wait = wait_until
1036 .map(|w| format!(", waitUntil: '{}'", w))
1037 .unwrap_or_default();
1038 format!(
1039 " results.push({{ action: 'navigate', success: true }});\n await page.goto('{}'{});\n",
1040 url, wait
1041 )
1042 }
1043 BrowserAction::Click { selector, button } => {
1044 let opts = button
1045 .map(|b| format!(", {{ button: '{}' }}", b))
1046 .unwrap_or_default();
1047 format!(
1048 " await page.click('{}'{});\n results.push({{ action: 'click', success: true }});\n",
1049 selector, opts
1050 )
1051 }
1052 BrowserAction::Fill { selector, value } => {
1053 format!(
1054 " await page.fill('{}', '{}');\n results.push({{ action: 'fill', success: true }});\n",
1055 selector, value
1056 )
1057 }
1058 BrowserAction::Press { key } => {
1059 format!(
1060 " await page.keyboard.press('{}');\n results.push({{ action: 'press', success: true }});\n",
1061 key
1062 )
1063 }
1064 BrowserAction::Select { selector, values } => {
1065 let vals: Vec<String> = values.iter().map(|v| format!("'{}'", v)).collect();
1066 format!(
1067 " await page.selectOption('{}', [{}]);\n results.push({{ action: 'select', success: true }});\n",
1068 selector, vals.join(", ")
1069 )
1070 }
1071 BrowserAction::Check { selector } => {
1072 format!(
1073 " await page.check('{}');\n results.push({{ action: 'check', success: true }});\n",
1074 selector
1075 )
1076 }
1077 BrowserAction::Uncheck { selector } => {
1078 format!(
1079 " await page.uncheck('{}');\n results.push({{ action: 'uncheck', success: true }});\n",
1080 selector
1081 )
1082 }
1083 BrowserAction::Hover { selector } => {
1084 format!(
1085 " await page.hover('{}');\n results.push({{ action: 'hover', success: true }});\n",
1086 selector
1087 )
1088 }
1089 BrowserAction::WaitForSelector { selector, state } => {
1090 let opts = state
1091 .map(|s| format!(", {{ state: '{}' }}", s))
1092 .unwrap_or_default();
1093 format!(
1094 " await page.waitForSelector('{}'{});\n results.push({{ action: 'waitForSelector', success: true }});\n",
1095 selector, opts
1096 )
1097 }
1098 BrowserAction::WaitForNavigation { url } => {
1099 let url_opt = url
1100 .as_ref()
1101 .map(|u| format!("{{ url: '{}' }}", u))
1102 .unwrap_or_default();
1103 format!(
1104 " await page.waitForNavigation({});\n results.push({{ action: 'waitForNavigation', success: true }});\n",
1105 url_opt
1106 )
1107 }
1108 BrowserAction::Screenshot { path, full_page } => {
1109 format!(
1110 " await page.screenshot({{ path: '{}', fullPage: {} }});\n results.push({{ action: 'screenshot', success: true, output: '{}' }});\n",
1111 path, full_page, path
1112 )
1113 }
1114 BrowserAction::GetText { selector } => {
1115 format!(
1116 " const text = await page.textContent('{}');\n results.push({{ action: 'getText', success: true, output: text || '' }});\n",
1117 selector
1118 )
1119 }
1120 BrowserAction::Evaluate { expression } => {
1121 format!(
1122 " const evalResult = await page.evaluate(`{}`);\n results.push({{ action: 'evaluate', success: true, output: String(evalResult) }});\n",
1123 expression
1124 )
1125 }
1126 BrowserAction::Upload { selector, files } => {
1127 let files_json = serde_json::to_string(files).unwrap_or_default();
1128 format!(
1129 " await page.setInputFiles('{}', {});\n results.push({{ action: 'upload', success: true }});\n",
1130 selector, files_json
1131 )
1132 }
1133 }
1134 }
1135
1136 fn parse_test_output(stdout: &str, stderr: &str) -> (u32, u32, u32, u32, u64) {
1144 let combined = format!("{}\n{}", stdout, stderr);
1145 let mut passed = 0u32;
1146 let mut failed = 0u32;
1147 let mut skipped = 0u32;
1148 let mut timed_out = 0u32;
1149 let mut duration_ms = 0u64;
1150
1151 for line in combined.lines() {
1152 let line_lower = line.to_lowercase();
1153
1154 if let Some(count) = Self::extract_count(&line_lower, "passed") {
1156 passed = count;
1157 }
1158 if let Some(count) = Self::extract_count(&line_lower, "failed") {
1159 failed = count;
1160 }
1161 if let Some(count) = Self::extract_count(&line_lower, "skipped") {
1162 skipped = count;
1163 }
1164 if let Some(count) = Self::extract_count(&line_lower, "timed out") {
1165 timed_out = count;
1166 }
1167
1168 if let Some(ms) = Self::extract_duration_ms(&line_lower) {
1170 duration_ms = ms;
1171 }
1172 }
1173
1174 (passed, failed, skipped, timed_out, duration_ms)
1175 }
1176
1177 fn extract_count(text: &str, keyword: &str) -> Option<u32> {
1179 if let Some(pos) = text.find(keyword) {
1181 let before = text[..pos].trim_end();
1182 let num: String = before
1184 .chars()
1185 .rev()
1186 .take_while(|c| c.is_ascii_digit())
1187 .collect::<String>()
1188 .chars()
1189 .rev()
1190 .collect();
1191 if let Ok(n) = num.parse::<u32>() {
1192 return Some(n);
1193 }
1194 }
1195 None
1196 }
1197
1198 fn extract_duration_ms(text: &str) -> Option<u64> {
1200 if let Some(pos) = text.find("ms") {
1202 let before = text[..pos].trim_end();
1203 let num: String = before
1204 .chars()
1205 .rev()
1206 .take_while(|c| c.is_ascii_digit())
1207 .collect::<String>()
1208 .chars()
1209 .rev()
1210 .collect();
1211 if let Ok(n) = num.parse::<u64>() {
1212 return Some(n);
1213 }
1214 }
1215
1216 if let Some(pos) = text.rfind(" in ") {
1218 let after = &text[pos + 4..];
1219 if let Some(dur) = Self::parse_seconds(after) {
1220 return Some(dur);
1221 }
1222 }
1223
1224 if let Some(paren_start) = text.rfind('(') {
1226 let after = &text[paren_start + 1..];
1227 if let Some(dur) = Self::parse_seconds(after) {
1228 return Some(dur);
1229 }
1230 }
1231
1232 None
1233 }
1234
1235 fn parse_seconds(text: &str) -> Option<u64> {
1237 let s_pos = text.find('s')?;
1238 let ms_pos = text.find("ms");
1240 if let Some(mp) = ms_pos {
1241 if s_pos >= mp {
1242 return None;
1243 }
1244 }
1245 let num_str = &text[..s_pos];
1246 let n = num_str.parse::<f64>().ok()?;
1247 Some((n * 1000.0) as u64)
1248 }
1249
1250 async fn run_npx<I, S>(&self, args: I) -> Result<Output>
1254 where
1255 I: IntoIterator<Item = S>,
1256 S: AsRef<std::ffi::OsStr>,
1257 {
1258 let mut cmd = Command::new("npx");
1259 cmd.arg("playwright");
1260 cmd.args(args);
1261 cmd.current_dir(self.working_dir());
1262
1263 let output = cmd.output().await.context("Failed to execute npx playwright")?;
1264 Ok(output)
1265 }
1266}
1267
1268impl fmt::Debug for PlaywrightCli {
1269 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1270 f.debug_struct("PlaywrightCli")
1271 .field("browser", &self.config.browser)
1272 .field("headless", &self.config.headless)
1273 .field("timeout_ms", &self.config.timeout_ms)
1274 .finish()
1275 }
1276}
1277
1278pub fn skill_instructions() -> String {
1286 let prompt = r#"# Playwright CLI Skill
1287
1288You are now operating in **playwright-cli mode**. Your goal is to automate
1289browser interactions, test web pages, and work with Playwright test suites.
1290
1291## Capabilities
1292
1293### 1. Browser Automation
1294- Navigate to URLs and wait for page conditions
1295- Click, type, fill forms, select options
1296- Take screenshots (full page or viewport)
1297- Extract text content from elements
1298- Execute arbitrary JavaScript in page context
1299- Upload files, check/uncheck checkboxes
1300- Hover over elements, press keyboard keys
1301
1302### 2. Page Testing
1303- Run full Playwright test suites
1304- Run individual test files
1305- Generate Playwright test code from actions
1306- Parse test results (passed, failed, skipped, timed out)
1307- Support multiple reporters (list, line, dot, html, json, junit)
1308
1309### 3. Multi-Browser Support
1310- Chromium (default)
1311- Firefox
1312- WebKit
1313
1314## Workflow
1315
1316### For Browser Automation
13171. Ensure Playwright is installed (`npx playwright --version`)
13182. If not installed, install it (`npm install --save-dev @playwright/test && npx playwright install`)
13193. Write a Node.js script using Playwright's API
13204. Execute the script via `node <script.js>`
13215. Parse the output
1322
1323### For Running Tests
13241. Verify the Playwright config file exists (`playwright.config.ts` or `playwright.config.js`)
13252. Run tests: `npx playwright test [path...] [options]`
13263. Parse the test output for pass/fail counts
13274. Review any failure details from the output
1328
1329### For Generating Tests
13301. Define the test scenario as a sequence of actions
13312. Generate a TypeScript test file using Playwright's test API
13323. Save the file and run it with `npx playwright test`
1333
1334## Guidelines
1335
1336- **Always use headless mode** unless the user explicitly requests headed mode
1337- **Set appropriate timeouts** — 30 seconds is the default, increase for slow pages
1338- **Wait for conditions** — use `waitForSelector` or `waitForNavigation` instead of arbitrary sleeps
1339- **Use specific selectors** — prefer `data-testid`, `aria-*`, or CSS selectors
1340- **Take screenshots on failure** — use the `--output` flag to capture artifacts
1341- **Clean up** — always close browsers in finally blocks
1342- **Test across browsers** — if multi-browser support matters, test with `--project` for each browser
1343
1344## Common Patterns
1345
1346### Quick Page Check
1347```bash
1348node -e "
1349const { chromium } = require('playwright');
1350(async () => {
1351 const browser = await chromium.launch();
1352 const page = await browser.newPage();
1353 await page.goto('https://example.com');
1354 const title = await page.title();
1355 console.log('Title:', title);
1356 await browser.close();
1357})();
1358"
1359```
1360
1361### Run Tests
1362```bash
1363npx playwright test tests/example.spec.ts --reporter=list
1364```
1365
1366### Take Screenshot
1367```bash
1368node -e "
1369const { chromium } = require('playwright');
1370(async () => {
1371 const browser = await chromium.launch();
1372 const page = await browser.newPage();
1373 await page.goto('https://example.com');
1374 await page.screenshot({ path: 'screenshot.png', fullPage: true });
1375 await browser.close();
1376})();
1377"
1378```
1379"#;
1380 prompt.to_string()
1381}
1382
1383#[cfg(test)]
1386mod tests {
1387 use super::*;
1388
1389 #[test]
1392 fn test_browser_default() {
1393 assert_eq!(Browser::default(), Browser::Chromium);
1394 }
1395
1396 #[test]
1397 fn test_browser_display() {
1398 assert_eq!(format!("{}", Browser::Chromium), "chromium");
1399 assert_eq!(format!("{}", Browser::Firefox), "firefox");
1400 assert_eq!(format!("{}", Browser::WebKit), "webkit");
1401 }
1402
1403 #[test]
1404 fn test_browser_from_str() {
1405 assert_eq!("chromium".parse::<Browser>().unwrap(), Browser::Chromium);
1406 assert_eq!("chrome".parse::<Browser>().unwrap(), Browser::Chromium);
1407 assert_eq!("firefox".parse::<Browser>().unwrap(), Browser::Firefox);
1408 assert_eq!("webkit".parse::<Browser>().unwrap(), Browser::WebKit);
1409 assert_eq!("safari".parse::<Browser>().unwrap(), Browser::WebKit);
1410 assert!("opera".parse::<Browser>().is_err());
1411 }
1412
1413 #[test]
1416 fn test_config_default() {
1417 let config = PlaywrightConfig::default();
1418 assert_eq!(config.browser, Browser::Chromium);
1419 assert!(config.headless);
1420 assert!(config.base_url.is_none());
1421 assert!(config.working_dir.is_none());
1422 assert_eq!(config.timeout_ms, 30_000);
1423 assert!(config.extra_args.is_empty());
1424 assert!(config.config_file.is_none());
1425 }
1426
1427 #[test]
1428 fn test_config_serde_roundtrip() {
1429 let config = PlaywrightConfig {
1430 browser: Browser::Firefox,
1431 headless: false,
1432 base_url: Some("http://localhost:3000".to_string()),
1433 working_dir: Some(PathBuf::from("/tmp/project")),
1434 timeout_ms: 60_000,
1435 extra_args: vec!["--disable-gpu".to_string()],
1436 config_file: Some(PathBuf::from("playwright.config.ts")),
1437 };
1438
1439 let json = serde_json::to_string(&config).unwrap();
1440 let parsed: PlaywrightConfig = serde_json::from_str(&json).unwrap();
1441 assert_eq!(parsed.browser, Browser::Firefox);
1442 assert!(!parsed.headless);
1443 assert_eq!(parsed.base_url, Some("http://localhost:3000".to_string()));
1444 assert_eq!(parsed.timeout_ms, 60_000);
1445 assert_eq!(parsed.extra_args.len(), 1);
1446 }
1447
1448 #[test]
1451 fn test_test_config_default() {
1452 let config = TestConfig::default();
1453 assert_eq!(config.test_paths, vec![".".to_string()]);
1454 assert!(config.project.is_none());
1455 assert_eq!(config.workers, 1);
1456 assert_eq!(config.retries, 0);
1457 assert_eq!(config.timeout_ms, 30_000);
1458 assert!(!config.update_snapshots);
1459 assert!(config.grep.is_none());
1460 assert_eq!(config.reporter, TestReporter::List);
1461 assert!(config.output_dir.is_none());
1462 }
1463
1464 #[test]
1467 fn test_wait_until_display() {
1468 assert_eq!(format!("{}", WaitUntil::Load), "load");
1469 assert_eq!(format!("{}", WaitUntil::DomContentLoaded), "domcontentloaded");
1470 assert_eq!(format!("{}", WaitUntil::NetworkIdle), "networkidle");
1471 assert_eq!(format!("{}", WaitUntil::Commit), "commit");
1472 }
1473
1474 #[test]
1475 fn test_wait_state_display() {
1476 assert_eq!(format!("{}", WaitState::Attached), "attached");
1477 assert_eq!(format!("{}", WaitState::Detached), "detached");
1478 assert_eq!(format!("{}", WaitState::Visible), "visible");
1479 assert_eq!(format!("{}", WaitState::Hidden), "hidden");
1480 }
1481
1482 #[test]
1483 fn test_mouse_button_display() {
1484 assert_eq!(format!("{}", MouseButton::Left), "left");
1485 assert_eq!(format!("{}", MouseButton::Right), "right");
1486 assert_eq!(format!("{}", MouseButton::Middle), "middle");
1487 }
1488
1489 #[test]
1490 fn test_reporter_display() {
1491 assert_eq!(format!("{}", TestReporter::List), "list");
1492 assert_eq!(format!("{}", TestReporter::Line), "line");
1493 assert_eq!(format!("{}", TestReporter::Dot), "dot");
1494 assert_eq!(format!("{}", TestReporter::Html), "html");
1495 assert_eq!(format!("{}", TestReporter::Json), "json");
1496 assert_eq!(format!("{}", TestReporter::Junit), "junit");
1497 }
1498
1499 #[test]
1500 fn test_reporter_default() {
1501 assert_eq!(TestReporter::default(), TestReporter::List);
1502 }
1503
1504 #[test]
1507 fn test_browser_action_navigate() {
1508 let action = BrowserAction::Navigate {
1509 url: "https://example.com".to_string(),
1510 wait_until: Some(WaitUntil::NetworkIdle),
1511 };
1512 let json = serde_json::to_string(&action).unwrap();
1513 assert!(json.contains("navigate"));
1514 assert!(json.contains("example.com"));
1515 assert!(json.contains("network-idle"));
1516 }
1517
1518 #[test]
1519 fn test_browser_action_click() {
1520 let action = BrowserAction::Click {
1521 selector: "#button".to_string(),
1522 button: Some(MouseButton::Right),
1523 };
1524 let json = serde_json::to_string(&action).unwrap();
1525 assert!(json.contains("click"));
1526 assert!(json.contains("#button"));
1527 assert!(json.contains("right"));
1528 }
1529
1530 #[test]
1531 fn test_browser_action_fill() {
1532 let action = BrowserAction::Fill {
1533 selector: "#input".to_string(),
1534 value: "hello world".to_string(),
1535 };
1536 let json = serde_json::to_string(&action).unwrap();
1537 assert!(json.contains("fill"));
1538 assert!(json.contains("hello world"));
1539 }
1540
1541 #[test]
1542 fn test_browser_action_screenshot() {
1543 let action = BrowserAction::Screenshot {
1544 path: "output.png".to_string(),
1545 full_page: true,
1546 };
1547 let json = serde_json::to_string(&action).unwrap();
1548 assert!(json.contains("screenshot"));
1549 assert!(json.contains("output.png"));
1550 }
1551
1552 #[test]
1553 fn test_browser_action_evaluate() {
1554 let action = BrowserAction::Evaluate {
1555 expression: "document.title".to_string(),
1556 };
1557 let json = serde_json::to_string(&action).unwrap();
1558 assert!(json.contains("evaluate"));
1559 assert!(json.contains("document.title"));
1560 }
1561
1562 #[test]
1563 fn test_browser_action_upload() {
1564 let action = BrowserAction::Upload {
1565 selector: "#file-input".to_string(),
1566 files: vec!["test.pdf".to_string(), "doc.txt".to_string()],
1567 };
1568 let json = serde_json::to_string(&action).unwrap();
1569 assert!(json.contains("upload"));
1570 assert!(json.contains("test.pdf"));
1571 }
1572
1573 #[test]
1576 fn test_action_to_playwright_code_navigate() {
1577 let action = BrowserAction::Navigate {
1578 url: "https://example.com".to_string(),
1579 wait_until: None,
1580 };
1581 let code = PlaywrightCli::action_to_playwright_code(&action);
1582 assert!(code.contains("page.goto('https://example.com')"));
1583 }
1584
1585 #[test]
1586 fn test_action_to_playwright_code_navigate_with_wait() {
1587 let action = BrowserAction::Navigate {
1588 url: "https://example.com".to_string(),
1589 wait_until: Some(WaitUntil::NetworkIdle),
1590 };
1591 let code = PlaywrightCli::action_to_playwright_code(&action);
1592 assert!(code.contains("waitUntil: 'networkidle'"));
1593 }
1594
1595 #[test]
1596 fn test_action_to_playwright_code_click() {
1597 let action = BrowserAction::Click {
1598 selector: "#btn".to_string(),
1599 button: None,
1600 };
1601 let code = PlaywrightCli::action_to_playwright_code(&action);
1602 assert!(code.contains("page.click('#btn')"));
1603 }
1604
1605 #[test]
1606 fn test_action_to_playwright_code_fill() {
1607 let action = BrowserAction::Fill {
1608 selector: "#search".to_string(),
1609 value: "rust".to_string(),
1610 };
1611 let code = PlaywrightCli::action_to_playwright_code(&action);
1612 assert!(code.contains("page.fill('#search', 'rust')"));
1613 }
1614
1615 #[test]
1616 fn test_action_to_playwright_code_press() {
1617 let action = BrowserAction::Press {
1618 key: "Enter".to_string(),
1619 };
1620 let code = PlaywrightCli::action_to_playwright_code(&action);
1621 assert!(code.contains("keyboard.press('Enter')"));
1622 }
1623
1624 #[test]
1625 fn test_action_to_playwright_code_select() {
1626 let action = BrowserAction::Select {
1627 selector: "#dropdown".to_string(),
1628 values: vec!["option1".to_string(), "option2".to_string()],
1629 };
1630 let code = PlaywrightCli::action_to_playwright_code(&action);
1631 assert!(code.contains("selectOption('#dropdown'"));
1632 assert!(code.contains("'option1'"));
1633 assert!(code.contains("'option2'"));
1634 }
1635
1636 #[test]
1637 fn test_action_to_playwright_code_screenshot() {
1638 let action = BrowserAction::Screenshot {
1639 path: "shot.png".to_string(),
1640 full_page: true,
1641 };
1642 let code = PlaywrightCli::action_to_playwright_code(&action);
1643 assert!(code.contains("screenshot"));
1644 assert!(code.contains("shot.png"));
1645 assert!(code.contains("fullPage: true"));
1646 }
1647
1648 #[test]
1649 fn test_action_to_playwright_code_get_text() {
1650 let action = BrowserAction::GetText {
1651 selector: "h1".to_string(),
1652 };
1653 let code = PlaywrightCli::action_to_playwright_code(&action);
1654 assert!(code.contains("textContent('h1')"));
1655 }
1656
1657 #[test]
1658 fn test_action_to_playwright_code_evaluate() {
1659 let action = BrowserAction::Evaluate {
1660 expression: "document.title".to_string(),
1661 };
1662 let code = PlaywrightCli::action_to_playwright_code(&action);
1663 assert!(code.contains("page.evaluate"));
1664 assert!(code.contains("document.title"));
1665 }
1666
1667 #[test]
1668 fn test_action_to_playwright_code_hover() {
1669 let action = BrowserAction::Hover {
1670 selector: ".menu-item".to_string(),
1671 };
1672 let code = PlaywrightCli::action_to_playwright_code(&action);
1673 assert!(code.contains("page.hover('.menu-item')"));
1674 }
1675
1676 #[test]
1677 fn test_action_to_playwright_code_check() {
1678 let action = BrowserAction::Check {
1679 selector: "#agree".to_string(),
1680 };
1681 let code = PlaywrightCli::action_to_playwright_code(&action);
1682 assert!(code.contains("page.check('#agree')"));
1683 }
1684
1685 #[test]
1686 fn test_action_to_playwright_code_uncheck() {
1687 let action = BrowserAction::Uncheck {
1688 selector: "#newsletter".to_string(),
1689 };
1690 let code = PlaywrightCli::action_to_playwright_code(&action);
1691 assert!(code.contains("page.uncheck('#newsletter')"));
1692 }
1693
1694 #[test]
1695 fn test_action_to_playwright_code_wait_for_selector() {
1696 let action = BrowserAction::WaitForSelector {
1697 selector: ".loaded".to_string(),
1698 state: Some(WaitState::Visible),
1699 };
1700 let code = PlaywrightCli::action_to_playwright_code(&action);
1701 assert!(code.contains("waitForSelector('.loaded'"));
1702 assert!(code.contains("state: 'visible'"));
1703 }
1704
1705 #[test]
1706 fn test_action_to_playwright_code_wait_for_selector_no_state() {
1707 let action = BrowserAction::WaitForSelector {
1708 selector: ".loaded".to_string(),
1709 state: None,
1710 };
1711 let code = PlaywrightCli::action_to_playwright_code(&action);
1712 assert!(code.contains("waitForSelector('.loaded')"));
1713 assert!(!code.contains("state"));
1714 }
1715
1716 #[test]
1717 fn test_action_to_playwright_code_wait_for_navigation() {
1718 let action = BrowserAction::WaitForNavigation {
1719 url: Some("https://example.com/success".to_string()),
1720 };
1721 let code = PlaywrightCli::action_to_playwright_code(&action);
1722 assert!(code.contains("waitForNavigation"));
1723 assert!(code.contains("example.com/success"));
1724 }
1725
1726 #[test]
1727 fn test_action_to_playwright_code_wait_for_navigation_no_url() {
1728 let action = BrowserAction::WaitForNavigation { url: None };
1729 let code = PlaywrightCli::action_to_playwright_code(&action);
1730 assert!(code.contains("waitForNavigation()"));
1731 }
1732
1733 #[test]
1734 fn test_action_to_playwright_code_upload() {
1735 let action = BrowserAction::Upload {
1736 selector: "#file".to_string(),
1737 files: vec!["a.pdf".to_string()],
1738 };
1739 let code = PlaywrightCli::action_to_playwright_code(&action);
1740 assert!(code.contains("setInputFiles('#file'"));
1741 assert!(code.contains("a.pdf"));
1742 }
1743
1744 #[test]
1747 fn test_generate_test_file() {
1748 let actions = vec![
1749 BrowserAction::Fill {
1750 selector: "#search".to_string(),
1751 value: "rust".to_string(),
1752 },
1753 BrowserAction::Click {
1754 selector: "#search-btn".to_string(),
1755 button: None,
1756 },
1757 BrowserAction::GetText {
1758 selector: "h1".to_string(),
1759 },
1760 ];
1761
1762 let code = PlaywrightCli::generate_test_file(
1763 "search works",
1764 "https://example.com",
1765 &actions,
1766 );
1767
1768 assert!(code.contains("import { test, expect }"));
1769 assert!(code.contains("test('search works'"));
1770 assert!(code.contains("page.goto('https://example.com')"));
1771 assert!(code.contains("page.fill('#search', 'rust')"));
1772 assert!(code.contains("page.click('#search-btn')"));
1773 assert!(code.contains("textContent('h1')"));
1774 }
1775
1776 #[test]
1779 fn test_generate_action_script() {
1780 let actions = vec![
1781 BrowserAction::Navigate {
1782 url: "https://example.com".to_string(),
1783 wait_until: None,
1784 },
1785 BrowserAction::Click {
1786 selector: "#btn".to_string(),
1787 button: None,
1788 },
1789 ];
1790
1791 let script = PlaywrightCli::generate_action_script(
1792 "https://example.com",
1793 &actions,
1794 true,
1795 30_000,
1796 );
1797
1798 assert!(script.contains("require('playwright')"));
1799 assert!(script.contains("chromium.launch"));
1800 assert!(script.contains("headless: true"));
1801 assert!(script.contains("page.goto"));
1802 assert!(script.contains("page.click('#btn')"));
1803 assert!(script.contains("browser.close"));
1804 }
1805
1806 #[test]
1809 fn test_parse_test_output_basic() {
1810 let stdout = "running 3 tests\n ✓ test 1\n ✓ test 2\n ✓ test 3\n\n3 passed (5s)";
1811 let (passed, failed, skipped, timed_out, duration_ms) =
1812 PlaywrightCli::parse_test_output(stdout, "");
1813 assert_eq!(passed, 3);
1814 assert_eq!(failed, 0);
1815 assert_eq!(skipped, 0);
1816 assert_eq!(timed_out, 0);
1817 assert_eq!(duration_ms, 5000);
1818 }
1819
1820 #[test]
1821 fn test_parse_test_output_mixed() {
1822 let stdout = "5 passed\n2 failed\n1 skipped\n1 timed out\nran in 2500ms";
1823 let (passed, failed, skipped, timed_out, duration_ms) =
1824 PlaywrightCli::parse_test_output(stdout, "");
1825 assert_eq!(passed, 5);
1826 assert_eq!(failed, 2);
1827 assert_eq!(skipped, 1);
1828 assert_eq!(timed_out, 1);
1829 assert_eq!(duration_ms, 2500);
1830 }
1831
1832 #[test]
1833 fn test_parse_test_output_from_stderr() {
1834 let stdout = "";
1835 let stderr = "3 passed (1.5s)";
1836 let (passed, _failed, _skipped, _timed_out, duration_ms) =
1837 PlaywrightCli::parse_test_output(stdout, stderr);
1838 assert_eq!(passed, 3);
1839 assert_eq!(duration_ms, 1500);
1840 }
1841
1842 #[test]
1843 fn test_parse_test_output_no_results() {
1844 let stdout = "some unrelated output";
1845 let (passed, failed, skipped, timed_out, duration_ms) =
1846 PlaywrightCli::parse_test_output(stdout, "");
1847 assert_eq!(passed, 0);
1848 assert_eq!(failed, 0);
1849 assert_eq!(skipped, 0);
1850 assert_eq!(timed_out, 0);
1851 assert_eq!(duration_ms, 0);
1852 }
1853
1854 #[test]
1857 fn test_extract_count_pattern() {
1858 assert_eq!(PlaywrightCli::extract_count("5 passed", "passed"), Some(5));
1859 assert_eq!(PlaywrightCli::extract_count("2 failed", "failed"), Some(2));
1860 assert_eq!(PlaywrightCli::extract_count("10 skipped", "skipped"), Some(10));
1861 assert_eq!(PlaywrightCli::extract_count("1 timed out", "timed out"), Some(1));
1862 }
1863
1864 #[test]
1865 fn test_extract_count_no_match() {
1866 assert_eq!(PlaywrightCli::extract_count("hello world", "passed"), None);
1867 }
1868
1869 #[test]
1872 fn test_extract_duration_ms_pattern() {
1873 assert_eq!(PlaywrightCli::extract_duration_ms("ran in 2500ms"), Some(2500));
1874 assert_eq!(PlaywrightCli::extract_duration_ms("finished in 100ms"), Some(100));
1875 }
1876
1877 #[test]
1878 fn test_extract_duration_seconds_pattern() {
1879 assert_eq!(PlaywrightCli::extract_duration_ms("ran in 5s"), Some(5000));
1880 assert_eq!(PlaywrightCli::extract_duration_ms("finished in 1.5s"), Some(1500));
1881 }
1882
1883 #[test]
1884 fn test_extract_duration_no_match() {
1885 assert_eq!(PlaywrightCli::extract_duration_ms("hello world"), None);
1886 }
1887
1888 #[test]
1891 fn test_cli_new() {
1892 let cli = PlaywrightCli::new(PlaywrightConfig::default());
1893 assert_eq!(cli.config().browser, Browser::Chromium);
1894 assert!(cli.config().headless);
1895 }
1896
1897 #[test]
1898 fn test_cli_with_browser() {
1899 let cli = PlaywrightCli::with_browser(Browser::Firefox);
1900 assert_eq!(cli.config().browser, Browser::Firefox);
1901 }
1902
1903 #[test]
1904 fn test_cli_debug() {
1905 let cli = PlaywrightCli::new(PlaywrightConfig::default());
1906 let debug = format!("{:?}", cli);
1907 assert!(debug.contains("PlaywrightCli"));
1908 assert!(debug.contains("Chromium"));
1909 }
1910
1911 #[test]
1914 fn test_test_result_total() {
1915 let result = TestResult {
1916 success: true,
1917 passed: 5,
1918 failed: 2,
1919 skipped: 1,
1920 timed_out: 0,
1921 duration_ms: 1000,
1922 stdout: String::new(),
1923 stderr: String::new(),
1924 exit_code: 0,
1925 };
1926 assert_eq!(result.total(), 8);
1927 }
1928
1929 #[test]
1932 fn test_action_result_output() {
1933 let result = ActionResult::output("hello");
1934 assert!(result.success);
1935 assert_eq!(result.output, Some("hello".to_string()));
1936 assert!(result.error.is_none());
1937 }
1938
1939 #[test]
1942 fn test_screenshot_result() {
1943 let result = ScreenshotResult {
1944 path: PathBuf::from("screenshot.png"),
1945 size_bytes: 12345,
1946 };
1947 assert_eq!(result.path, PathBuf::from("screenshot.png"));
1948 assert_eq!(result.size_bytes, 12345);
1949 }
1950
1951 #[test]
1954 fn test_skill_instructions_not_empty() {
1955 let instructions = skill_instructions();
1956 assert!(!instructions.is_empty());
1957 assert!(instructions.contains("Playwright CLI Skill"));
1958 assert!(instructions.contains("Browser Automation"));
1959 assert!(instructions.contains("Page Testing"));
1960 assert!(instructions.contains("Multi-Browser Support"));
1961 assert!(instructions.contains("chromium"));
1962 }
1963
1964 #[test]
1967 fn test_command_output() {
1968 let output = CommandOutput {
1969 stdout: "hello".to_string(),
1970 stderr: String::new(),
1971 success: true,
1972 exit_code: 0,
1973 };
1974 assert!(output.success);
1975 assert_eq!(output.stdout, "hello");
1976 assert_eq!(output.exit_code, 0);
1977 }
1978
1979 #[test]
1982 fn test_test_result_serde_roundtrip() {
1983 let result = TestResult {
1984 success: true,
1985 passed: 10,
1986 failed: 1,
1987 skipped: 2,
1988 timed_out: 0,
1989 duration_ms: 5000,
1990 stdout: "output".to_string(),
1991 stderr: String::new(),
1992 exit_code: 1,
1993 };
1994 let json = serde_json::to_string(&result).unwrap();
1995 let parsed: TestResult = serde_json::from_str(&json).unwrap();
1996 assert_eq!(parsed.passed, 10);
1997 assert_eq!(parsed.failed, 1);
1998 assert_eq!(parsed.skipped, 2);
1999 assert_eq!(parsed.duration_ms, 5000);
2000 }
2001
2002 #[test]
2003 fn test_action_results_serde_roundtrip() {
2004 let results = ActionResults {
2005 success: true,
2006 actions_total: 3,
2007 results: vec![
2008 ActionResult {
2009 action: "navigate".to_string(),
2010 success: true,
2011 output: None,
2012 error: None,
2013 },
2014 ActionResult {
2015 action: "click".to_string(),
2016 success: true,
2017 output: Some("clicked".to_string()),
2018 error: None,
2019 },
2020 ],
2021 stdout: "out".to_string(),
2022 stderr: String::new(),
2023 };
2024 let json = serde_json::to_string(&results).unwrap();
2025 let parsed: ActionResults = serde_json::from_str(&json).unwrap();
2026 assert!(parsed.success);
2027 assert_eq!(parsed.actions_total, 3);
2028 assert_eq!(parsed.results.len(), 2);
2029 }
2030
2031 #[test]
2032 fn test_screenshot_result_serde_roundtrip() {
2033 let result = ScreenshotResult {
2034 path: PathBuf::from("screenshots/home.png"),
2035 size_bytes: 54321,
2036 };
2037 let json = serde_json::to_string(&result).unwrap();
2038 let parsed: ScreenshotResult = serde_json::from_str(&json).unwrap();
2039 assert_eq!(parsed.path, PathBuf::from("screenshots/home.png"));
2040 assert_eq!(parsed.size_bytes, 54321);
2041 }
2042
2043 #[test]
2044 fn test_test_config_serde_roundtrip() {
2045 let config = TestConfig {
2046 test_paths: vec!["tests/".to_string()],
2047 project: Some("chromium".to_string()),
2048 repeat_each: 2,
2049 retries: 3,
2050 workers: 4,
2051 timeout_ms: 60_000,
2052 update_snapshots: true,
2053 grep: Some("login".to_string()),
2054 reporter: TestReporter::Json,
2055 output_dir: Some(PathBuf::from("test-results")),
2056 working_dir: Some(PathBuf::from("/tmp/project")),
2057 };
2058 let json = serde_json::to_string(&config).unwrap();
2059 let parsed: TestConfig = serde_json::from_str(&json).unwrap();
2060 assert_eq!(parsed.test_paths, vec!["tests/".to_string()]);
2061 assert_eq!(parsed.project, Some("chromium".to_string()));
2062 assert_eq!(parsed.workers, 4);
2063 assert_eq!(parsed.reporter, TestReporter::Json);
2064 assert!(parsed.update_snapshots);
2065 }
2066}