Skip to main content

oxi/skills/
playwright_cli.rs

1//! Playwright CLI skill for oxi
2//!
3//! Provides browser automation capabilities through Playwright:
4//! - Launching and managing browser instances
5//! - Navigating to URLs and waiting for page conditions
6//! - Executing page interactions (click, type, select, screenshot)
7//! - Running Playwright test suites and collecting results
8//! - Generating Playwright test code
9//!
10//! The skill does NOT embed a browser engine. Instead, it orchestrates the
11//! system-installed `npx playwright` CLI via subprocess calls, and provides
12//! typed Rust abstractions over the common workflows.
13//!
14//! This module provides both:
15//! - A [`PlaywrightCli`] struct for programmatic browser automation
16//! - A [`skill_instructions`] function that produces system-prompt content
17//!   for the LLM-driven browser testing workflow
18
19use 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// ── Configuration ────────────────────────────────────────────────────
27
28/// Browser type to use for automation.
29#[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/// Configuration for a Playwright CLI invocation.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PlaywrightConfig {
72    /// Browser to use (default: Chromium).
73    #[serde(default)]
74    pub browser: Browser,
75
76    /// Whether to run in headless mode (default: true).
77    #[serde(default = "default_true")]
78    pub headless: bool,
79
80    /// Base URL to navigate to before performing actions.
81    pub base_url: Option<String>,
82
83    /// Working directory for the Playwright process.
84    pub working_dir: Option<PathBuf>,
85
86    /// Timeout in milliseconds for page operations (default: 30000).
87    #[serde(default = "default_timeout")]
88    pub timeout_ms: u64,
89
90    /// Additional arguments to pass to the browser.
91    #[serde(default)]
92    pub extra_args: Vec<String>,
93
94    /// Path to the Playwright configuration file.
95    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// ── Action types ─────────────────────────────────────────────────────
121
122/// A single browser action to perform.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(tag = "action", rename_all = "snake_case")]
125pub enum BrowserAction {
126    /// Navigate to a URL.
127    Navigate {
128        url: String,
129        /// Optional: wait until this condition is met.
130        #[serde(skip_serializing_if = "Option::is_none")]
131        wait_until: Option<WaitUntil>,
132    },
133
134    /// Click on an element.
135    Click {
136        selector: String,
137        #[serde(skip_serializing_if = "Option::is_none")]
138        button: Option<MouseButton>,
139    },
140
141    /// Type text into an element.
142    Fill {
143        selector: String,
144        value: String,
145    },
146
147    /// Press a keyboard key combination.
148    Press {
149        key: String,
150    },
151
152    /// Select an option in a `<select>` element.
153    Select {
154        selector: String,
155        values: Vec<String>,
156    },
157
158    /// Check a checkbox or radio button.
159    Check {
160        selector: String,
161    },
162
163    /// Uncheck a checkbox.
164    Uncheck {
165        selector: String,
166    },
167
168    /// Hover over an element.
169    Hover {
170        selector: String,
171    },
172
173    /// Wait for an element to appear.
174    WaitForSelector {
175        selector: String,
176        #[serde(skip_serializing_if = "Option::is_none")]
177        state: Option<WaitState>,
178    },
179
180    /// Wait for a navigation to complete.
181    WaitForNavigation {
182        #[serde(skip_serializing_if = "Option::is_none")]
183        url: Option<String>,
184    },
185
186    /// Take a screenshot.
187    Screenshot {
188        /// Output file path.
189        path: String,
190        /// Whether to capture the full page (default: viewport only).
191        #[serde(default)]
192        full_page: bool,
193    },
194
195    /// Get the text content of an element.
196    GetText {
197        selector: String,
198    },
199
200    /// Evaluate JavaScript in the page context.
201    Evaluate {
202        expression: String,
203    },
204
205    /// Upload files to a file input.
206    Upload {
207        selector: String,
208        files: Vec<String>,
209    },
210}
211
212/// Wait condition for navigation.
213#[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/// Wait state for an element.
234#[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/// Mouse button for click actions.
255#[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// ── Test-related types ───────────────────────────────────────────────
274
275/// Configuration for running a Playwright test suite.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct TestConfig {
278    /// Test file pattern(s) or paths to run.
279    #[serde(default = "default_test_paths")]
280    pub test_paths: Vec<String>,
281
282    /// Project name from playwright.config to use.
283    pub project: Option<String>,
284
285    /// Repeat count for each test.
286    #[serde(default)]
287    pub repeat_each: u32,
288
289    /// Number of retries for failed tests.
290    #[serde(default)]
291    pub retries: u32,
292
293    /// Number of parallel workers (default: 1).
294    #[serde(default = "default_workers")]
295    pub workers: u32,
296
297    /// Timeout per test in milliseconds.
298    #[serde(default = "default_timeout")]
299    pub timeout_ms: u64,
300
301    /// Whether to update snapshots.
302    #[serde(default)]
303    pub update_snapshots: bool,
304
305    /// Glob pattern to filter tests.
306    pub grep: Option<String>,
307
308    /// Reporter format.
309    #[serde(default)]
310    pub reporter: TestReporter,
311
312    /// Output directory for test artifacts.
313    pub output_dir: Option<PathBuf>,
314
315    /// Working directory.
316    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/// Test reporter format.
346#[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/// Result of a Playwright test run.
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct TestResult {
379    /// Whether all tests passed.
380    pub success: bool,
381    /// Number of tests that passed.
382    pub passed: u32,
383    /// Number of tests that failed.
384    pub failed: u32,
385    /// Number of tests skipped.
386    pub skipped: u32,
387    /// Number of tests that timed out.
388    pub timed_out: u32,
389    /// Total test duration in milliseconds.
390    pub duration_ms: u64,
391    /// Raw stdout output.
392    pub stdout: String,
393    /// Raw stderr output.
394    pub stderr: String,
395    /// Exit code of the Playwright process.
396    pub exit_code: i32,
397}
398
399impl TestResult {
400    /// Total number of tests.
401    pub fn total(&self) -> u32 {
402        self.passed + self.failed + self.skipped + self.timed_out
403    }
404}
405
406// ── Screenshot result ────────────────────────────────────────────────
407
408/// Result of taking a screenshot.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct ScreenshotResult {
411    /// Path where the screenshot was saved.
412    pub path: PathBuf,
413    /// File size in bytes.
414    pub size_bytes: u64,
415}
416
417// ── Command output ───────────────────────────────────────────────────
418
419/// Output from a subprocess command.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct CommandOutput {
422    /// stdout content.
423    pub stdout: String,
424    /// stderr content.
425    pub stderr: String,
426    /// Whether the command exited successfully.
427    pub success: bool,
428    /// Process exit code.
429    pub exit_code: i32,
430}
431
432// ── Action results ───────────────────────────────────────────────────
433
434/// Result of executing a sequence of browser actions.
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct ActionResults {
437    /// Whether the overall execution succeeded.
438    pub success: bool,
439    /// Total number of actions that were requested.
440    pub actions_total: usize,
441    /// Individual results for each action.
442    pub results: Vec<ActionResult>,
443    /// Combined stdout from the script execution.
444    pub stdout: String,
445    /// Combined stderr from the script execution.
446    pub stderr: String,
447}
448
449/// Result of a single browser action.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct ActionResult {
452    /// The action that was performed.
453    pub action: String,
454    /// Whether this action succeeded.
455    pub success: bool,
456    /// Output data (text content, screenshot path, JS result, etc.).
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub output: Option<String>,
459    /// Error message if the action failed.
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub error: Option<String>,
462}
463
464impl ActionResult {
465    /// Create a successful action result with output.
466    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
476// ── Main Playwright CLI skill ────────────────────────────────────────
477
478/// The Playwright CLI skill.
479///
480/// Provides typed methods for browser automation via the `npx playwright` CLI.
481/// All methods spawn subprocess calls to Playwright — this module does not
482/// embed a browser engine.
483///
484/// # Example
485///
486/// ```rust,no_run
487/// use oxi::skills::playwright_cli::{PlaywrightCli, PlaywrightConfig};
488///
489/// #[tokio::main]
490/// async fn main() -> anyhow::Result<()> {
491///     let cli = PlaywrightCli::new(PlaywrightConfig::default());
492///
493///     // Check if Playwright is installed
494///     cli.ensure_installed().await?;
495///
496///     // Run a quick automation script
497///     cli.run_script(
498///         "const { chromium } = require('playwright'); ..."
499///     ).await?;
500///
501///     Ok(())
502/// }
503/// ```
504pub struct PlaywrightCli {
505    config: PlaywrightConfig,
506}
507
508impl PlaywrightCli {
509    /// Create a new Playwright CLI instance with the given configuration.
510    pub fn new(config: PlaywrightConfig) -> Self {
511        Self { config }
512    }
513
514    /// Create with default configuration and a specific browser.
515    pub fn with_browser(browser: Browser) -> Self {
516        Self {
517            config: PlaywrightConfig {
518                browser,
519                ..Default::default()
520            },
521        }
522    }
523
524    /// Access the current configuration.
525    pub fn config(&self) -> &PlaywrightConfig {
526        &self.config
527    }
528
529    /// Resolve the working directory.
530    fn working_dir(&self) -> &Path {
531        self.config
532            .working_dir
533            .as_deref()
534            .unwrap_or_else(|| Path::new("."))
535    }
536
537    // ── Installation / setup ─────────────────────────────────────────
538
539    /// Check whether the Playwright CLI is available.
540    ///
541    /// Runs `npx playwright --version` to verify the CLI is accessible.
542    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    /// Ensure Playwright is installed, installing it if necessary.
558    ///
559    /// 1. Checks if the CLI is available.
560    /// 2. If not, runs `npm install` for `@playwright/test`.
561    /// 3. Then installs browser binaries via `npx playwright install`.
562    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        // Install the npm package
571        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        // Install browser binaries
584        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    /// Install a specific browser (or all browsers if `None`).
597    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    // ── Browser automation via scripts ───────────────────────────────
613
614    /// Run a JavaScript snippet using Node.js with Playwright.
615    ///
616    /// The script should use Playwright's API. Example:
617    /// ```javascript
618    /// const { chromium } = require('playwright');
619    /// (async () => {
620    ///     const browser = await chromium.launch();
621    ///     const page = await browser.newPage();
622    ///     await page.goto('https://example.com');
623    ///     const title = await page.title();
624    ///     console.log(title);
625    ///     await browser.close();
626    /// })();
627    /// ```
628    pub async fn run_script(&self, script: &str) -> Result<CommandOutput> {
629        // Write script to a temp file
630        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    /// Run a fully-formed Playwright automation sequence.
651    ///
652    /// Takes a list of [`BrowserAction`]s and generates a Node.js script
653    /// that executes them in order, then returns the results.
654    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            // Try to parse the stdout as JSON results
669            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    /// Take a screenshot of a URL.
693    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        // Parse the JSON from stdout
734        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    /// Get the text content of an element on a page.
749    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    // ── Test runner ──────────────────────────────────────────────────
786
787    /// Run a Playwright test suite.
788    pub async fn run_tests(&self, test_config: &TestConfig) -> Result<TestResult> {
789        let mut args = vec!["test".to_string()];
790
791        // Test paths
792        args.extend(test_config.test_paths.iter().cloned());
793
794        // Project
795        if let Some(ref project) = test_config.project {
796            args.push("--project".to_string());
797            args.push(project.clone());
798        }
799
800        // Workers
801        args.push("--workers".to_string());
802        args.push(test_config.workers.to_string());
803
804        // Retries
805        if test_config.retries > 0 {
806            args.push("--retries".to_string());
807            args.push(test_config.retries.to_string());
808        }
809
810        // Repeat
811        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        // Timeout
817        args.push("--timeout".to_string());
818        args.push(test_config.timeout_ms.to_string());
819
820        // Reporter
821        args.push("--reporter".to_string());
822        args.push(test_config.reporter.to_string());
823
824        // Update snapshots
825        if test_config.update_snapshots {
826            args.push("--update-snapshots".to_string());
827        }
828
829        // Grep
830        if let Some(ref grep) = test_config.grep {
831            args.push("--grep".to_string());
832            args.push(grep.clone());
833        }
834
835        // Output dir
836        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        // Config file from global config or test config
842        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        // Parse test counts from output
867        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    /// Run a single test file.
884    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    /// Generate a Playwright test file from a list of page interactions.
893    ///
894    /// Produces a complete TypeScript test file that can be run with
895    /// `npx playwright test`.
896    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    // ── Code generation ──────────────────────────────────────────────
922
923    /// Generate a complete Node.js script for a sequence of actions.
924    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    /// Convert a browser action to Playwright test code (for test file generation).
963    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    /// Convert a browser action to standalone Node.js code (for script generation).
1032    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    /// Parse test result counts from Playwright output.
1137    ///
1138    /// Looks for patterns like:
1139    /// - `5 passed`
1140    /// - `2 failed`
1141    /// - `1 skipped`
1142    /// - `ran in 1234ms` or `finished in 5s`
1143    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            // Match patterns like "5 passed", "2 failed", "1 skipped"
1155            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            // Duration patterns
1169            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    /// Extract a count from a string like "5 passed" or "passed (5)".
1178    fn extract_count(text: &str, keyword: &str) -> Option<u32> {
1179        // Try pattern: "N keyword"
1180        if let Some(pos) = text.find(keyword) {
1181            let before = text[..pos].trim_end();
1182            // Find the number at the end of `before`
1183            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    /// Extract duration in milliseconds from patterns like "ran in 1234ms" or "finished in 5s".
1199    fn extract_duration_ms(text: &str) -> Option<u64> {
1200        // Pattern: "Nms" — extract number right before "ms"
1201        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        // Pattern: "Ns" (seconds, not ms) — e.g. "3 passed (5s)" or "ran in 5s"
1217        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        // Pattern: "(Ns)" at end of string — e.g. "3 passed (5s)"
1225        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    /// Parse a seconds value from a string like "5s)" or "1.5s".
1236    fn parse_seconds(text: &str) -> Option<u64> {
1237        let s_pos = text.find('s')?;
1238        // Make sure this 's' is not part of 'ms'
1239        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    // ── Low-level command execution ──────────────────────────────────
1251
1252    /// Run `npx playwright` with the given arguments.
1253    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
1278// ── Skill instructions ───────────────────────────────────────────────
1279
1280/// Generate the skill instructions to be injected into the system prompt
1281/// when the Playwright CLI skill is active.
1282///
1283/// This tells the LLM how to use browser automation and testing via
1284/// Playwright through the available tools (bash, read, write).
1285pub 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// ── Tests ────────────────────────────────────────────────────────────
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388
1389    // ── Browser type tests ──────────────────────────────────────────
1390
1391    #[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    // ── Config tests ────────────────────────────────────────────────
1414
1415    #[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 config tests ───────────────────────────────────────────
1449
1450    #[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    // ── Enum display tests ──────────────────────────────────────────
1465
1466    #[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    // ── BrowserAction serialization tests ───────────────────────────
1505
1506    #[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    // ── Code generation tests ───────────────────────────────────────
1574
1575    #[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 file generation ────────────────────────────────────────
1745
1746    #[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    // ── Action script generation ────────────────────────────────────
1777
1778    #[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 result parsing ─────────────────────────────────────────
1807
1808    #[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    // ── Extract count tests ─────────────────────────────────────────
1855
1856    #[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    // ── Extract duration tests ──────────────────────────────────────
1870
1871    #[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    // ── PlaywrightCli construction tests ────────────────────────────
1889
1890    #[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 result tests ───────────────────────────────────────────
1912
1913    #[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    // ── Action result tests ─────────────────────────────────────────
1930
1931    #[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    // ── Screenshot result tests ─────────────────────────────────────
1940
1941    #[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    // ── Skill instructions test ─────────────────────────────────────
1952
1953    #[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    // ── Command output tests ────────────────────────────────────────
1965
1966    #[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    // ── Serde roundtrip tests ───────────────────────────────────────
1980
1981    #[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}