use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
fn parse_timeout_scale(value: &str) -> Result<f64, String> {
let scale = value
.parse::<f64>()
.map_err(|_| "timeout scale must be a number".to_owned())?;
if scale.is_finite() && (1.0..=10.0).contains(&scale) {
Ok(scale)
} else {
Err("timeout scale must be between 1 and 10".to_owned())
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum CheckMode {
Isolated,
Sweep,
}
#[derive(Parser)]
#[command(
name = "ps-qa",
about = "Drive a Blitz application through its control socket and judge what the renderer did.",
long_about = None,
version,
)]
pub struct Cli {
#[arg(long, global = true)]
pub descriptor: Option<PathBuf>,
#[arg(long, global = true)]
pub app: Option<PathBuf>,
#[arg(long, global = true, default_value_t = 1.0 / 60.0)]
pub pace: f64,
#[arg(
long,
global = true,
default_value_t = 1.0,
value_parser = parse_timeout_scale
)]
pub timeout_scale: f64,
#[arg(long, global = true)]
pub trace: bool,
#[arg(long, global = true)]
pub trace_capture: bool,
#[arg(long, global = true)]
pub require_paint_events: bool,
#[arg(long, global = true)]
pub pixel_artifact_dir: Option<PathBuf>,
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand)]
pub enum Command {
Nodes,
Panes,
Idle,
Blink {
#[arg(default_value_t = 0)]
allowed_missed: u64,
},
Ghost {
#[arg(default_value_t = 64.0)]
min_area: f64,
#[arg(default_value_t = 400)]
max: usize,
},
Drift {
#[arg(default_value_t = 20.0)]
seconds: f64,
},
Frames,
Metrics,
Tree,
Find {
#[arg(default_value = "*")]
pattern: String,
#[arg(long)]
role: Vec<String>,
#[arg(long)]
visible: bool,
#[arg(long)]
hidden: bool,
#[arg(long)]
painted: bool,
#[arg(long)]
offscreen: bool,
#[arg(long)]
disabled: bool,
#[arg(long)]
count: bool,
#[arg(long)]
limit: Option<usize>,
},
Layout {
#[arg(default_value = "")]
name: String,
},
Dom {
name: String,
#[arg(default_value_t = 6)]
depth: usize,
},
Transcript,
Paint {
#[arg(default_value = "")]
name: String,
#[arg(default_value_t = 10000.0)]
min_area: f64,
},
Contrast {
#[arg(default_value = "")]
name: String,
#[arg(long, default_value_t = 4.5)]
text_ratio: f64,
#[arg(long, default_value_t = 3.0)]
control_ratio: f64,
},
Spill {
#[arg(default_value = "h")]
axis: String,
#[arg(default_value_t = 1.0)]
tolerance: f64,
},
Watch {
#[arg(default_value_t = 20.0)]
seconds: f64,
},
Scroll {
#[arg(default_value_t = 120)]
ticks: u32,
#[arg(default_value_t = -80.0)]
delta: f64,
#[arg(default_value = "")]
over: String,
},
Drag {
#[arg(default_value = "")]
name: String,
#[arg(default_value_t = -400.0)]
dy: f64,
#[arg(default_value_t = 10)]
steps: u32,
},
Type {
#[arg(default_value_t = 20)]
count: u32,
#[arg(default_value = "")]
name: String,
},
Key {
name: String,
#[arg(default_value_t = 1)]
count: u32,
#[arg(default_value = "")]
over: String,
},
Reveal {
name: String,
},
Capture {
#[arg(default_value = "")]
name: String,
#[arg(default_value_t = 1.0)]
scale: f64,
#[arg(long)]
output: Option<std::path::PathBuf>,
},
Press {
name: String,
},
Click {
#[arg(required_unless_present = "id")]
name: Option<String>,
#[arg(long, conflicts_with = "name")]
id: Option<u64>,
},
Audit {
family: Option<String>,
},
Sweep {
family: Option<String>,
},
Cover {
surface: Option<String>,
#[arg(long)]
unmapped_only: bool,
#[arg(long)]
checks: Option<PathBuf>,
#[arg(long, default_value_t = 180)]
max_seconds: u64,
},
Inventory {
surface: Option<String>,
#[arg(long)]
require_outcomes: bool,
#[arg(long)]
checks: Option<PathBuf>,
},
Reconcile {
inventory: PathBuf,
#[arg(long)]
checks: Option<PathBuf>,
},
Qa {
selector: Option<String>,
#[arg(long)]
checks: Option<PathBuf>,
},
QaHosted {
selector: Option<String>,
#[arg(long)]
host: PathBuf,
#[arg(long)]
page: PathBuf,
#[arg(long)]
checks: Option<PathBuf>,
#[arg(long, default_value_t = 30)]
startup_timeout: u64,
},
List {
#[arg(long)]
checks: Option<PathBuf>,
},
SweepComponents {
ids: Vec<String>,
#[arg(long)]
host: PathBuf,
#[arg(long)]
dists: PathBuf,
#[arg(long)]
checks: Option<PathBuf>,
#[arg(long, default_value_t = 30)]
startup_timeout: u64,
#[arg(long, value_enum, default_value_t = CheckMode::Isolated)]
mode: CheckMode,
},
}
static TRACE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static TRACE_CAPTURE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static REQUIRE_PAINT_EVENTS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
static PIXEL_ARTIFACT_DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
static PACE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
static TIMEOUT_SCALE: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(1.0_f64.to_bits());
pub fn set_trace(on: bool) {
TRACE.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn set_capture_options(
trace_capture: bool,
require_paint_events: bool,
pixel_artifact_dir: Option<PathBuf>,
) {
TRACE_CAPTURE.store(trace_capture, std::sync::atomic::Ordering::Relaxed);
REQUIRE_PAINT_EVENTS.store(require_paint_events, std::sync::atomic::Ordering::Relaxed);
let _ = PIXEL_ARTIFACT_DIR.set(pixel_artifact_dir);
}
pub fn set_pace(seconds: f64) {
PACE.store(seconds.to_bits(), std::sync::atomic::Ordering::Relaxed);
}
pub fn set_timeout_scale(scale: f64) {
TIMEOUT_SCALE.store(scale.to_bits(), std::sync::atomic::Ordering::Relaxed);
}
static APP: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
pub fn set_app_profile(path: Option<PathBuf>) {
let _ = APP.set(path);
}
pub fn app_profile() -> Option<PathBuf> {
APP.get().cloned().flatten()
}
pub fn pace() -> f64 {
f64::from_bits(PACE.load(std::sync::atomic::Ordering::Relaxed))
}
pub fn timeout_scale() -> f64 {
f64::from_bits(TIMEOUT_SCALE.load(std::sync::atomic::Ordering::Relaxed))
}
pub fn trace() -> bool {
TRACE.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn trace_capture() -> bool {
TRACE_CAPTURE.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn require_paint_events() -> bool {
REQUIRE_PAINT_EVENTS.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn pixel_artifact_dir() -> Option<PathBuf> {
PIXEL_ARTIFACT_DIR.get().cloned().flatten()
}
impl Command {
pub fn requires_app_profile(&self) -> bool {
matches!(
self,
Command::Sweep { .. }
| Command::Cover { .. }
| Command::Inventory { .. }
| Command::Qa { .. }
| Command::QaHosted { .. }
| Command::SweepComponents { .. }
)
}
pub fn is_dump(&self) -> bool {
matches!(
self,
Command::Metrics | Command::Watch { .. } | Command::Frames | Command::Tree
)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use clap::Parser;
use super::{CheckMode, Cli, Command, parse_timeout_scale};
#[test]
fn timeout_scale_is_explicit_and_bounded() {
assert_eq!(parse_timeout_scale("1").unwrap(), 1.0);
assert_eq!(parse_timeout_scale("2.5").unwrap(), 2.5);
assert!(parse_timeout_scale("0.5").is_err());
assert!(parse_timeout_scale("11").is_err());
assert!(parse_timeout_scale("not-a-number").is_err());
}
#[test]
fn only_application_driving_modes_require_a_profile() {
assert!(Command::Sweep { family: None }.requires_app_profile());
assert!(
Command::Qa {
selector: None,
checks: None,
}
.requires_app_profile()
);
assert!(
Command::QaHosted {
selector: None,
host: PathBuf::from("host"),
page: PathBuf::from("page.html"),
checks: None,
startup_timeout: 30,
}
.requires_app_profile()
);
assert!(
Command::SweepComponents {
ids: Vec::new(),
host: PathBuf::from("host"),
dists: PathBuf::from("dist"),
checks: None,
startup_timeout: 30,
mode: CheckMode::Isolated,
}
.requires_app_profile()
);
assert!(!Command::Metrics.requires_app_profile());
assert!(
!Command::Click {
name: None,
id: Some(7)
}
.requires_app_profile()
);
}
#[test]
fn hosted_qa_arguments_are_typed_by_clap() {
let cli = Cli::try_parse_from([
"ps-qa",
"--app",
"fixture.ron",
"qa-hosted",
"fixture-text-entry",
"--host",
"qa-inspect-host",
"--page",
"page.html",
"--checks",
"checks",
])
.unwrap();
let Command::QaHosted {
selector,
host,
page,
checks,
startup_timeout,
} = cli.command
else {
panic!("qa-hosted did not parse as the hosted QA command");
};
assert_eq!(selector.as_deref(), Some("fixture-text-entry"));
assert_eq!(host, PathBuf::from("qa-inspect-host"));
assert_eq!(page, PathBuf::from("page.html"));
assert_eq!(checks, Some(PathBuf::from("checks")));
assert_eq!(startup_timeout, 30);
}
}