mod client;
mod daemon;
mod protocol;
mod pw_ext;
use crate::protocol::Command;
use clap::{CommandFactory, Parser, Subcommand};
use std::collections::HashSet;
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser)]
#[command(
name = "plwr",
about = "Playwright CLI for browser automation using CSS selectors",
after_long_help = EXAMPLES,
after_help = "Use --help for examples",
disable_help_subcommand = true,
version,
)]
struct Cli {
#[arg(
short = 'S',
long,
global = true,
env = "PLWR_SESSION",
default_value = "default"
)]
session: String,
#[arg(
short = 'T',
long,
global = true,
env = "PLWR_TIMEOUT",
default_value_t = 5000
)]
timeout: u64,
#[command(subcommand)]
command: Cmd,
}
const EXAMPLES: &str = "\x1b[1;4mExamples:\x1b[0m
Start the browser and navigate:
plwr start # start headless browser
plwr start --headed # start with visible window
plwr open https://example.com
plwr text h1 # \"Example Domain\"
plwr attr a href # \"https://www.iana.org/...\"
plwr stop
Fill a form and submit:
plwr fill '#email' 'alice@test.com'
plwr fill '#password' 'hunter2'
plwr click 'button[type=submit]'
plwr wait '.dashboard' # wait for redirect
When a selector matches multiple elements:
plwr click 'li.item >> nth=0' # first match
plwr click 'li.item >> nth=2' # third match
plwr text ':nth-match(li.item, 2)' # alternative syntax
Chain with shell conditionals:
plwr exists '.cookie-banner' && plwr click '.accept-cookies'
Set headers for authenticated requests:
plwr header Authorization 'Bearer tok_xxx'
plwr open https://api.example.com/dashboard
Manage cookies:
plwr cookie session_id abc123
plwr cookie --list # show all as JSON
plwr cookie --clear
Run JavaScript:
plwr eval 'document.title'
plwr eval '({count: document.querySelectorAll(\"li\").length})'
Inspect the DOM:
plwr tree '.sidebar' # JSON tree of element
plwr count '.search-result' # number of matches
Screenshot and video:
plwr screenshot --selector '.chart' --path chart.png
plwr video-start
plwr click '#run-demo'
plwr video-stop demo.mp4
Adjust viewport for responsive testing:
plwr viewport 375 667 # iPhone SE
plwr screenshot --path mobile.png
plwr viewport 1280 720 # desktop
Keyboard input:
plwr press Enter
plwr press Control+a # select all
plwr press Meta+c # copy (macOS)
Sessions — each session is an independent browser with its own
cookies, headers, and page state:
plwr -S admin start
plwr -S user start --headed
plwr -S admin open https://app.com/admin
plwr -S user open https://app.com/login
plwr -S user fill '#email' 'user@test.com'
plwr -S admin text '.active-users' # check admin view
plwr -S admin stop
plwr -S user stop
Wait for one of several outcomes:
plwr wait-any '.success-msg' '.error-msg' # prints which matched
plwr wait-all '.header' '.sidebar' '.main' # all must appear
Custom timeout:
plwr wait '.slow-element' -T 30000 # wait up to 30s
\x1b[1;4mSelector reference:\x1b[0m
Playwright extends CSS selectors with extra features.
CSS selectors (all standard CSS works):
plwr click '#submit-btn' # by id
plwr click '.btn.primary' # compound class
plwr count 'input[type=email]' # attribute match
plwr count 'input:checked' # pseudo-class
plwr count 'input:disabled' # form state
plwr count 'input:required' # form validation
plwr count 'div:empty' # empty elements
plwr click 'li:first-child' # positional
plwr click 'li:last-child' # positional
plwr count '#list > li' # child combinator
plwr count 'h1 + p' # adjacent sibling
plwr count 'h1 ~ p' # general sibling
plwr count 'a[href^=/]' # starts with
plwr count 'a[href$=.pdf]' # ends with
plwr count 'a[href*=example]' # contains
plwr count 'a[download]' # has attribute
plwr click 'li:not(.done)' # negation
plwr click '.card:has(img)' # has descendant
Playwright extensions:
plwr click ':has-text(\"Sign in\")' # contains text
plwr click 'text=Sign in' # text shorthand
plwr click 'li.item >> nth=0' # pick nth match
plwr click ':visible' # only visible
plwr text 'tr:has-text(\"Bob\") >> td.status'
# chain with >>
Some CSS pseudo-classes need the css= prefix to avoid
Playwright's selector parser misinterpreting them:
plwr text 'css=span:last-of-type' # ✓ works
plwr text 'span:last-of-type' # ✗ misinterpreted
plwr text 'css=li:nth-of-type(2)' # ✓ works
plwr text 'css=:is(.card, .sidebar)' # ✓ works
plwr text 'css=[data-id=\"login\"]' # ✓ quoted attrs
The css= prefix is needed for: :last-of-type, :first-of-type,
:nth-of-type(), :nth-last-child(), :is(), :where(),
and quoted attribute values [attr=\"val\"].
These work without the prefix: :nth-child(), :first-child,
:last-child, :not(), :has(), :empty, :checked, :disabled,
:enabled, :required, :visible, :has-text().
\x1b[1;4mEnvironment variables:\x1b[0m
PLAYWRIGHT_HEADED Show browser window (set to any value)
PLWR_SESSION Default session name (default: \"default\")
PLWR_TIMEOUT Default timeout in ms (default: 5000)
PLWR_IGNORE_CERT_ERRORS Ignore TLS/SSL certificate errors
PLWR_CDP Chrome channel for CDP connection (stable, beta, canary, dev)";
#[derive(Subcommand)]
enum Cmd {
Start {
#[arg(long)]
headed: bool,
#[arg(long)]
video: Option<String>,
#[arg(long)]
ignore_cert_errors: bool,
#[arg(long, env = "PLWR_CDP", num_args = 0..=1, default_missing_value = "stable")]
cdp: Option<String>,
},
Stop,
Open { url: String },
Reload,
Url,
Wait { selector: String },
WaitNot { selector: String },
WaitAny {
#[arg(required = true)]
selectors: Vec<String>,
},
WaitAll {
#[arg(required = true)]
selectors: Vec<String>,
},
Click {
selector: String,
#[arg(long)]
right: bool,
#[arg(long)]
middle: bool,
#[arg(long)]
alt: bool,
#[arg(long, alias = "ctrl")]
control: bool,
#[arg(long)]
meta: bool,
#[arg(long)]
shift: bool,
},
Fill { selector: String, text: String },
Press { key: String },
Type {
text: String,
#[arg(long)]
delay: Option<f64>,
},
Exists { selector: String },
Text { selector: String },
Attr { selector: String, name: String },
Count { selector: String },
Cookie {
name: Option<String>,
value: Option<String>,
#[arg(long)]
url: Option<String>,
#[arg(long)]
list: bool,
#[arg(long)]
clear: bool,
},
Viewport {
width: u32,
height: u32,
},
Header {
name: Option<String>,
value: Option<String>,
#[arg(long)]
clear: bool,
},
InputFiles {
selector: String,
#[arg(trailing_var_arg = true)]
paths: Vec<String>,
},
Select {
selector: String,
#[arg(required = true)]
values: Vec<String>,
#[arg(long)]
label: bool,
},
Hover { selector: String },
Check { selector: String },
Uncheck { selector: String },
Dblclick {
selector: String,
#[arg(long)]
right: bool,
#[arg(long)]
middle: bool,
#[arg(long)]
alt: bool,
#[arg(long, alias = "ctrl")]
control: bool,
#[arg(long)]
meta: bool,
#[arg(long)]
shift: bool,
},
Focus { selector: String },
Blur { selector: String },
InnerHtml { selector: String },
InputValue { selector: String },
Scroll { selector: String },
ClipboardCopy { selector: String },
ClipboardPaste,
ComputedStyle {
selector: String,
#[arg(trailing_var_arg = true)]
properties: Vec<String>,
},
NextDialog {
action: String,
text: Option<String>,
},
Console {
#[arg(long)]
clear: bool,
},
Network {
#[arg(long)]
clear: bool,
#[arg(long, value_delimiter = ',')]
r#type: Vec<String>,
#[arg(long)]
url: Option<String>,
#[arg(long)]
include_ws_messages: bool,
},
Eval { js: String },
Screenshot {
#[arg(long)]
selector: Option<String>,
#[arg(long, default_value = "screenshot.png")]
path: String,
},
Tree {
selector: Option<String>,
},
#[command(hide = true)]
Daemon,
}
fn find_subcommand_in_args() -> Option<String> {
let cmd = Cli::command();
let names: HashSet<String> = cmd
.get_subcommands()
.flat_map(|s| {
let mut names = vec![s.get_name().to_string()];
names.extend(s.get_all_aliases().map(String::from));
names
})
.collect();
std::env::args().skip(1).find(|a| names.contains(a))
}
fn socket_path(session: &str) -> PathBuf {
let dir = dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("plwr");
std::fs::create_dir_all(&dir).ok();
dir.join(format!("{}.sock", session))
}
#[tokio::main]
async fn main() -> ExitCode {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
match e.kind() {
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
e.exit()
}
_ => {
let rendered = e.render().ansi().to_string();
let msg = if let Some(idx) = rendered.find("Usage:") {
let before = &rendered[..idx];
let cut = before.rfind('\n').unwrap_or(idx);
rendered[..cut].trim_end()
} else {
rendered.trim_end()
};
eprintln!("{}\n", msg);
if let Some(name) = find_subcommand_in_args() {
let mut cmd = Cli::command();
if let Some(sub) = cmd.find_subcommand_mut(&name) {
let mut sub = sub
.clone()
.bin_name(format!("plwr {}", name))
.help_template("{usage-heading} {usage}\n\n{all-args}");
sub.print_help().ok();
}
}
return ExitCode::FAILURE;
}
}
}
};
let sock = socket_path(&cli.session);
match cli.command {
Cmd::Daemon => {
let headed = std::env::var("PLAYWRIGHT_HEADED").is_ok_and(|v| !v.is_empty());
let ignore_cert_errors =
std::env::var("PLWR_IGNORE_CERT_ERRORS").is_ok_and(|v| !v.is_empty());
match daemon::run(&sock, headed, ignore_cert_errors).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
std::fs::remove_file(&sock).ok();
eprintln!("{}", e);
ExitCode::FAILURE
}
}
}
Cmd::Start {
headed,
video,
ignore_cert_errors,
cdp,
} => {
let headed = headed || std::env::var("PLAYWRIGHT_HEADED").is_ok_and(|v| !v.is_empty());
if cdp.is_some() && headed {
eprintln!(
"--cdp and --headed are mutually exclusive (the browser is already visible)"
);
return ExitCode::FAILURE;
}
if cdp.is_some() && video.is_some() {
eprintln!("--cdp and --video are mutually exclusive (video recording requires a launched browser)");
return ExitCode::FAILURE;
}
let ignore_cert_errors = ignore_cert_errors
|| std::env::var("PLWR_IGNORE_CERT_ERRORS").is_ok_and(|v| !v.is_empty());
match client::ensure_started(
&sock,
headed,
video.as_deref(),
ignore_cert_errors,
cdp.as_deref(),
)
.await
{
Ok(()) => {
println!("Started session '{}'", cli.session);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{}", e);
ExitCode::FAILURE
}
}
}
Cmd::Stop => match client::send_if_running(&sock, Command::Stop).await {
Ok(Some(_)) => {
println!("Stopped session '{}'", cli.session);
ExitCode::SUCCESS
}
Ok(None) => {
println!("No session '{}' running", cli.session);
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("{}", e);
ExitCode::FAILURE
}
},
cmd => {
let command = match cmd {
Cmd::Daemon | Cmd::Stop | Cmd::Start { .. } => unreachable!(),
Cmd::Open { url } => Command::Open {
url,
timeout: cli.timeout,
},
Cmd::Reload => Command::Reload,
Cmd::Url => Command::Url,
Cmd::Wait { selector } => Command::Wait {
selector,
timeout: cli.timeout,
},
Cmd::WaitNot { selector } => Command::WaitNot {
selector,
timeout: cli.timeout,
},
Cmd::WaitAny { selectors } => Command::WaitAny {
selectors,
timeout: cli.timeout,
},
Cmd::WaitAll { selectors } => Command::WaitAll {
selectors,
timeout: cli.timeout,
},
Cmd::Click {
selector,
right,
middle,
alt,
control,
meta,
shift,
} => {
let mut modifiers = Vec::new();
if alt {
modifiers.push("Alt".to_string());
}
if control {
modifiers.push("Control".to_string());
}
if meta {
modifiers.push("Meta".to_string());
}
if shift {
modifiers.push("Shift".to_string());
}
let button = if right {
Some("right".to_string())
} else if middle {
Some("middle".to_string())
} else {
None
};
Command::Click {
selector,
timeout: cli.timeout,
modifiers,
button,
}
}
Cmd::Fill { selector, text } => Command::Fill {
selector,
text,
timeout: cli.timeout,
},
Cmd::Press { key } => Command::Press { key },
Cmd::Type { text, delay } => Command::Type { text, delay },
Cmd::Exists { selector } => Command::Exists { selector },
Cmd::Cookie { list: true, .. } => Command::CookieList,
Cmd::Cookie { clear: true, .. } => Command::CookieClear,
Cmd::Cookie {
name: Some(name),
value: Some(value),
url,
..
} => {
let url = url.unwrap_or_default();
Command::Cookie { name, value, url }
}
Cmd::Cookie {
name: Some(name),
value: None,
..
} => {
eprintln!("Usage: plwr cookie <name> <value> [--url <url>], plwr cookie --list, or plwr cookie --clear");
eprintln!("Missing value for cookie '{}'", name);
return ExitCode::FAILURE;
}
Cmd::Cookie { .. } => {
eprintln!("Usage: plwr cookie <name> <value> [--url <url>], plwr cookie --list, or plwr cookie --clear");
return ExitCode::FAILURE;
}
Cmd::Viewport { width, height } => Command::Viewport { width, height },
Cmd::Header { clear: true, .. } => Command::HeaderClear,
Cmd::Header {
name: Some(name),
value: Some(value),
..
} => Command::Header { name, value },
Cmd::Header {
name: Some(name),
value: None,
..
} => {
eprintln!("Usage: plwr header <name> <value> or plwr header --clear");
eprintln!("Missing value for header '{}'", name);
return ExitCode::FAILURE;
}
Cmd::Header { name: None, .. } => {
eprintln!("Usage: plwr header <name> <value> or plwr header --clear");
return ExitCode::FAILURE;
}
Cmd::Text { selector } => Command::Text {
selector,
timeout: cli.timeout,
},
Cmd::Attr { selector, name } => Command::Attr {
selector,
name,
timeout: cli.timeout,
},
Cmd::Count { selector } => Command::Count { selector },
Cmd::InputFiles { selector, paths } => Command::InputFiles {
selector,
paths,
timeout: cli.timeout,
},
Cmd::Select {
selector,
values,
label,
} => Command::Select {
selector,
values,
by_label: label,
timeout: cli.timeout,
},
Cmd::Hover { selector } => Command::Hover {
selector,
timeout: cli.timeout,
},
Cmd::Check { selector } => Command::Check {
selector,
timeout: cli.timeout,
},
Cmd::Uncheck { selector } => Command::Uncheck {
selector,
timeout: cli.timeout,
},
Cmd::Dblclick {
selector,
right,
middle,
alt,
control,
meta,
shift,
} => {
let mut modifiers = Vec::new();
if alt {
modifiers.push("Alt".to_string());
}
if control {
modifiers.push("Control".to_string());
}
if meta {
modifiers.push("Meta".to_string());
}
if shift {
modifiers.push("Shift".to_string());
}
let button = if right {
Some("right".to_string())
} else if middle {
Some("middle".to_string())
} else {
None
};
Command::Dblclick {
selector,
timeout: cli.timeout,
modifiers,
button,
}
}
Cmd::Focus { selector } => Command::Focus {
selector,
timeout: cli.timeout,
},
Cmd::Blur { selector } => Command::Blur {
selector,
timeout: cli.timeout,
},
Cmd::InnerHtml { selector } => Command::InnerHtml {
selector,
timeout: cli.timeout,
},
Cmd::InputValue { selector } => Command::InputValue {
selector,
timeout: cli.timeout,
},
Cmd::Scroll { selector } => Command::ScrollIntoView {
selector,
timeout: cli.timeout,
},
Cmd::NextDialog { action, text } => match action.as_str() {
"accept" => Command::DialogAccept { prompt_text: text },
"dismiss" => Command::DialogDismiss,
other => {
eprintln!(
"Unknown dialog action '{}'. Use 'accept' or 'dismiss'.",
other
);
return ExitCode::FAILURE;
}
},
Cmd::Console { clear: true } => Command::ConsoleClear,
Cmd::Console { clear: false } => Command::Console,
Cmd::Network { clear: true, .. } => Command::NetworkClear,
Cmd::Network {
clear: false,
r#type,
url,
include_ws_messages,
} => Command::Network {
types: r#type,
url_pattern: url,
include_ws_messages,
},
Cmd::ClipboardCopy { selector } => Command::ClipboardCopy {
selector,
timeout: cli.timeout,
},
Cmd::ClipboardPaste => Command::ClipboardPaste,
Cmd::ComputedStyle {
selector,
properties,
} => Command::ComputedStyle {
selector,
properties,
timeout: cli.timeout,
},
Cmd::Eval { js } => Command::Eval { js },
Cmd::Screenshot { selector, path } => Command::Screenshot {
selector,
path,
timeout: cli.timeout,
},
Cmd::Tree { selector } => Command::Tree {
selector,
timeout: cli.timeout,
},
};
match client::send(&sock, command).await {
Ok(resp) => {
if resp.ok {
if let Some(value) = resp.value {
match value {
serde_json::Value::String(s) => println!("{}", s),
serde_json::Value::Bool(b) => {
if !b {
return ExitCode::FAILURE;
}
}
serde_json::Value::Null => {}
other => {
println!("{}", serde_json::to_string_pretty(&other).unwrap())
}
}
}
ExitCode::SUCCESS
} else {
eprintln!("{}", resp.error.unwrap_or_else(|| "Unknown error".into()));
ExitCode::FAILURE
}
}
Err(e) => {
eprintln!("{}", e);
ExitCode::FAILURE
}
}
}
}
}