#[derive(Debug, Clone)]
pub enum StringOrRegex {
String(String),
Regex { source: String, flags: String },
}
impl StringOrRegex {
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::String(s) => Some(s),
Self::Regex { .. } => None,
}
}
#[must_use]
pub fn regex(source: impl Into<String>, flags: impl Into<String>) -> Self {
Self::Regex {
source: source.into(),
flags: flags.into(),
}
}
}
impl From<&str> for StringOrRegex {
fn from(s: &str) -> Self {
Self::String(s.to_string())
}
}
impl From<String> for StringOrRegex {
fn from(s: String) -> Self {
Self::String(s)
}
}
#[derive(Debug, Clone, Default)]
pub struct RoleOptions {
pub name: Option<StringOrRegex>,
pub exact: Option<bool>,
pub checked: Option<bool>,
pub disabled: Option<bool>,
pub expanded: Option<bool>,
pub level: Option<i32>,
pub pressed: Option<bool>,
pub selected: Option<bool>,
pub include_hidden: Option<bool>,
}
#[derive(Debug, Clone, Default)]
pub struct TextOptions {
pub exact: Option<bool>,
}
#[derive(Debug, Clone)]
pub enum LocatorLike {
Locator(crate::locator::Locator),
Selector(String),
}
impl LocatorLike {
#[must_use]
pub fn as_selector(&self) -> &str {
match self {
Self::Locator(l) => l.selector(),
Self::Selector(s) => s.as_str(),
}
}
#[must_use]
pub fn as_locator(&self) -> Option<&crate::locator::Locator> {
match self {
Self::Locator(l) => Some(l),
Self::Selector(_) => None,
}
}
}
impl From<crate::locator::Locator> for LocatorLike {
fn from(l: crate::locator::Locator) -> Self {
Self::Locator(l)
}
}
impl From<String> for LocatorLike {
fn from(s: String) -> Self {
Self::Selector(s)
}
}
impl From<&str> for LocatorLike {
fn from(s: &str) -> Self {
Self::Selector(s.to_string())
}
}
impl From<&String> for LocatorLike {
fn from(s: &String) -> Self {
Self::Selector(s.clone())
}
}
#[derive(Debug, Clone)]
pub enum InitScriptSource {
Function { body: String },
Source(String),
Path(std::path::PathBuf),
Content(String),
}
impl From<String> for InitScriptSource {
fn from(s: String) -> Self {
Self::Source(s)
}
}
impl From<&str> for InitScriptSource {
fn from(s: &str) -> Self {
Self::Source(s.to_string())
}
}
impl From<std::path::PathBuf> for InitScriptSource {
fn from(p: std::path::PathBuf) -> Self {
Self::Path(p)
}
}
pub fn evaluation_script(
script: InitScriptSource,
arg: Option<&serde_json::Value>,
) -> Result<String, crate::error::FerriError> {
match script {
InitScriptSource::Function { body } => {
let arg_str = match arg {
None => "undefined".to_string(),
Some(v) => serde_json::to_string(v)?,
};
Ok(format!("({body})({arg_str})"))
},
InitScriptSource::Source(s) | InitScriptSource::Content(s) => {
if arg.is_some() {
return Err(crate::error::FerriError::invalid_argument(
"arg",
"Cannot evaluate a string with arguments",
));
}
Ok(s)
},
InitScriptSource::Path(p) => {
if arg.is_some() {
return Err(crate::error::FerriError::invalid_argument(
"arg",
"Cannot evaluate a string with arguments",
));
}
let source = std::fs::read_to_string(&p)?;
let safe_path = p.display().to_string().replace('\n', "");
Ok(format!("{source}\n//# sourceURL={safe_path}"))
},
}
}
#[derive(Debug, Clone, Default)]
pub struct FilterOptions {
pub has_text: Option<String>,
pub has_not_text: Option<String>,
pub has: Option<LocatorLike>,
pub has_not: Option<LocatorLike>,
pub visible: Option<bool>,
}
#[derive(Debug, Clone, Default)]
pub struct WaitOptions {
pub state: Option<String>,
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct EvaluateOptions {
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AriaSnapshotMode {
#[default]
Default,
Ai,
}
impl AriaSnapshotMode {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
AriaSnapshotMode::Default => "default",
AriaSnapshotMode::Ai => "ai",
}
}
#[must_use]
pub fn from_opt_str(s: Option<&str>) -> Self {
match s {
Some("ai") => AriaSnapshotMode::Ai,
_ => AriaSnapshotMode::Default,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AriaSnapshotOptions {
pub mode: Option<AriaSnapshotMode>,
pub depth: Option<i32>,
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct ScreenshotOptions {
pub animations: Option<String>,
pub caret: Option<String>,
pub clip: Option<ClipRect>,
pub full_page: Option<bool>,
pub format: Option<String>,
pub mask: Vec<String>,
pub mask_color: Option<String>,
pub omit_background: Option<bool>,
pub path: Option<std::path::PathBuf>,
pub quality: Option<i64>,
pub scale: Option<String>,
pub style: Option<String>,
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClipRect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct BoundingBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct Point {
pub x: f64,
pub y: f64,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MouseButton {
#[default]
Left,
Right,
Middle,
}
impl MouseButton {
#[must_use]
pub fn as_cdp(self) -> &'static str {
match self {
Self::Left => "left",
Self::Right => "right",
Self::Middle => "middle",
}
}
#[must_use]
pub fn as_bidi(self) -> u8 {
match self {
Self::Left => 0,
Self::Middle => 1,
Self::Right => 2,
}
}
#[must_use]
pub fn as_webkit(self) -> u8 {
match self {
Self::Left => 0,
Self::Right => 1,
Self::Middle => 2,
}
}
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s {
"left" => Some(Self::Left),
"right" => Some(Self::Right),
"middle" => Some(Self::Middle),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Modifier {
Alt,
Control,
ControlOrMeta,
Meta,
Shift,
}
impl Modifier {
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s {
"Alt" => Some(Self::Alt),
"Control" => Some(Self::Control),
"ControlOrMeta" => Some(Self::ControlOrMeta),
"Meta" => Some(Self::Meta),
"Shift" => Some(Self::Shift),
_ => None,
}
}
#[must_use]
pub fn cdp_bit(self) -> u8 {
match self {
Self::Alt => 1,
Self::Control => 2,
Self::Meta => 4,
Self::Shift => 8,
Self::ControlOrMeta => {
if cfg!(target_os = "macos") {
4
} else {
2
}
},
}
}
#[must_use]
pub fn key_name(self) -> &'static str {
match self {
Self::Alt => "Alt",
Self::Control => "Control",
Self::Meta => "Meta",
Self::Shift => "Shift",
Self::ControlOrMeta => {
if cfg!(target_os = "macos") {
"Meta"
} else {
"Control"
}
},
}
}
#[must_use]
pub fn key_code(self) -> &'static str {
match self {
Self::Alt => "AltLeft",
Self::Control => "ControlLeft",
Self::Shift => "ShiftLeft",
Self::Meta => "MetaLeft",
Self::ControlOrMeta => {
if cfg!(target_os = "macos") {
"MetaLeft"
} else {
"ControlLeft"
}
},
}
}
}
#[must_use]
pub fn modifiers_bitmask(mods: &[Modifier]) -> u32 {
let mut m = 0u32;
for md in mods {
m |= u32::from(md.cdp_bit());
}
m
}
#[derive(Debug, Clone, Default)]
pub struct ClickOptions {
pub button: Option<MouseButton>,
pub click_count: Option<u32>,
pub delay: Option<u64>,
pub force: Option<bool>,
pub modifiers: Vec<Modifier>,
pub no_wait_after: Option<bool>,
pub position: Option<Point>,
pub steps: Option<u32>,
pub timeout: Option<u64>,
pub trial: Option<bool>,
}
impl ClickOptions {
#[must_use]
pub fn resolved_button(&self) -> MouseButton {
self.button.unwrap_or(MouseButton::Left)
}
#[must_use]
pub fn resolved_click_count(&self) -> u32 {
self.click_count.unwrap_or(1)
}
#[must_use]
pub fn resolved_delay_ms(&self) -> u64 {
self.delay.unwrap_or(0)
}
#[must_use]
pub fn resolved_steps(&self) -> u32 {
self.steps.unwrap_or(1).max(1)
}
#[must_use]
pub fn is_force(&self) -> bool {
self.force.unwrap_or(false)
}
#[must_use]
pub fn is_trial(&self) -> bool {
self.trial.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default)]
pub struct FillOptions {
pub force: Option<bool>,
pub no_wait_after: Option<bool>,
pub timeout: Option<u64>,
}
impl FillOptions {
#[must_use]
pub fn is_force(&self) -> bool {
self.force.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default)]
pub struct PressOptions {
pub delay: Option<u64>,
pub no_wait_after: Option<bool>,
pub timeout: Option<u64>,
}
impl PressOptions {
#[must_use]
pub fn resolved_delay_ms(&self) -> u64 {
self.delay.unwrap_or(0)
}
}
#[derive(Debug, Clone, Default)]
pub struct TypeOptions {
pub delay: Option<u64>,
pub no_wait_after: Option<bool>,
pub timeout: Option<u64>,
}
impl TypeOptions {
#[must_use]
pub fn resolved_delay_ms(&self) -> u64 {
self.delay.unwrap_or(0)
}
}
#[derive(Debug, Clone, Default)]
pub struct CheckOptions {
pub force: Option<bool>,
pub no_wait_after: Option<bool>,
pub position: Option<Point>,
pub timeout: Option<u64>,
pub trial: Option<bool>,
}
impl CheckOptions {
#[must_use]
pub fn is_force(&self) -> bool {
self.force.unwrap_or(false)
}
#[must_use]
pub fn is_trial(&self) -> bool {
self.trial.unwrap_or(false)
}
#[must_use]
pub fn into_click_options(self) -> ClickOptions {
ClickOptions {
button: None,
click_count: None,
delay: None,
force: self.force,
modifiers: Vec::new(),
no_wait_after: self.no_wait_after,
position: self.position,
steps: None,
timeout: self.timeout,
trial: self.trial,
}
}
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct SelectOptionValue {
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<u32>,
}
impl SelectOptionValue {
#[must_use]
pub fn by_value(s: impl Into<String>) -> Self {
Self {
value: Some(s.into()),
..Self::default()
}
}
#[must_use]
pub fn by_label(s: impl Into<String>) -> Self {
Self {
label: Some(s.into()),
..Self::default()
}
}
#[must_use]
pub fn by_index(i: u32) -> Self {
Self {
index: Some(i),
..Self::default()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct SelectOptionOptions {
pub force: Option<bool>,
pub no_wait_after: Option<bool>,
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct SetInputFilesOptions {
pub no_wait_after: Option<bool>,
pub timeout: Option<u64>,
}
#[derive(Debug, Clone)]
pub struct FilePayload {
pub name: String,
pub mime_type: String,
pub buffer: Vec<u8>,
}
#[derive(Debug, Clone)]
pub enum InputFiles {
Paths(Vec<std::path::PathBuf>),
Payloads(Vec<FilePayload>),
}
#[derive(Debug, Clone, Default)]
pub struct DispatchEventOptions {
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct HoverOptions {
pub force: Option<bool>,
pub modifiers: Vec<Modifier>,
pub no_wait_after: Option<bool>,
pub position: Option<Point>,
pub timeout: Option<u64>,
pub trial: Option<bool>,
}
impl HoverOptions {
#[must_use]
pub fn is_force(&self) -> bool {
self.force.unwrap_or(false)
}
#[must_use]
pub fn is_trial(&self) -> bool {
self.trial.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default)]
pub struct TapOptions {
pub force: Option<bool>,
pub modifiers: Vec<Modifier>,
pub no_wait_after: Option<bool>,
pub position: Option<Point>,
pub timeout: Option<u64>,
pub trial: Option<bool>,
}
impl TapOptions {
#[must_use]
pub fn is_force(&self) -> bool {
self.force.unwrap_or(false)
}
#[must_use]
pub fn is_trial(&self) -> bool {
self.trial.unwrap_or(false)
}
}
#[derive(Debug, Clone, Default)]
pub struct DblClickOptions {
pub button: Option<MouseButton>,
pub delay: Option<u64>,
pub force: Option<bool>,
pub modifiers: Vec<Modifier>,
pub no_wait_after: Option<bool>,
pub position: Option<Point>,
pub steps: Option<u32>,
pub timeout: Option<u64>,
pub trial: Option<bool>,
}
impl DblClickOptions {
#[must_use]
pub fn into_click_options(self) -> ClickOptions {
ClickOptions {
button: self.button,
click_count: Some(2),
delay: self.delay,
force: self.force,
modifiers: self.modifiers,
no_wait_after: self.no_wait_after,
position: self.position,
steps: self.steps,
timeout: self.timeout,
trial: self.trial,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DragAndDropOptions {
pub force: Option<bool>,
pub no_wait_after: Option<bool>,
pub source_position: Option<Point>,
pub target_position: Option<Point>,
pub steps: Option<u32>,
pub strict: Option<bool>,
pub timeout: Option<u64>,
pub trial: Option<bool>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ViewportConfig {
pub width: i64,
pub height: i64,
pub device_scale_factor: f64,
pub is_mobile: bool,
pub has_touch: bool,
pub is_landscape: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum MediaOverride {
#[default]
Unchanged,
Disabled,
Set(String),
}
impl MediaOverride {
#[must_use]
pub fn as_value(&self) -> Option<&str> {
match self {
Self::Set(v) => Some(v.as_str()),
_ => None,
}
}
#[must_use]
pub fn is_specified(&self) -> bool {
!matches!(self, Self::Unchanged)
}
}
impl From<Option<String>> for MediaOverride {
fn from(o: Option<String>) -> Self {
o.map_or(Self::Unchanged, Self::Set)
}
}
#[derive(Debug, Clone, Default)]
pub struct EmulateMediaOptions {
pub media: MediaOverride,
pub color_scheme: MediaOverride,
pub reduced_motion: MediaOverride,
pub forced_colors: MediaOverride,
pub contrast: MediaOverride,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PdfSize {
Pixels(f64),
Inches(f64),
Centimeters(f64),
Millimeters(f64),
}
impl PdfSize {
#[must_use]
pub fn to_inches(&self) -> f64 {
match *self {
Self::Pixels(v) => v / 96.0,
Self::Inches(v) => v,
Self::Centimeters(v) => v * 37.8 / 96.0,
Self::Millimeters(v) => v * 3.78 / 96.0,
}
}
pub fn parse(text: &str) -> crate::error::Result<Self> {
let trimmed = text.trim();
let (num_str, unit) = if trimmed.len() >= 2 {
let (head, tail) = trimmed.split_at(trimmed.len() - 2);
match tail.to_ascii_lowercase().as_str() {
"px" => (head, "px"),
"in" => (head, "in"),
"cm" => (head, "cm"),
"mm" => (head, "mm"),
_ => (trimmed, "px"),
}
} else {
(trimmed, "px")
};
let value: f64 = num_str.trim().parse().map_err(|_| {
crate::error::FerriError::invalid_argument("pdf size", format!("cannot parse numeric portion of {text:?}"))
})?;
Ok(match unit {
"px" => Self::Pixels(value),
"in" => Self::Inches(value),
"cm" => Self::Centimeters(value),
"mm" => Self::Millimeters(value),
_ => unreachable!("unit matched above"),
})
}
}
#[derive(Debug, Clone, Default)]
pub struct PdfMargin {
pub top: Option<PdfSize>,
pub right: Option<PdfSize>,
pub bottom: Option<PdfSize>,
pub left: Option<PdfSize>,
}
#[derive(Debug, Clone, Default)]
pub struct PdfOptions {
pub format: Option<String>,
pub path: Option<std::path::PathBuf>,
pub scale: Option<f64>,
pub display_header_footer: Option<bool>,
pub header_template: Option<String>,
pub footer_template: Option<String>,
pub print_background: Option<bool>,
pub landscape: Option<bool>,
pub page_ranges: Option<String>,
pub width: Option<PdfSize>,
pub height: Option<PdfSize>,
pub margin: Option<PdfMargin>,
pub prefer_css_page_size: Option<bool>,
pub outline: Option<bool>,
pub tagged: Option<bool>,
}
#[must_use]
pub fn pdf_paper_format_size(format: &str) -> Option<(f64, f64)> {
match format.to_ascii_lowercase().as_str() {
"letter" => Some((8.5, 11.0)),
"legal" => Some((8.5, 14.0)),
"tabloid" => Some((11.0, 17.0)),
"ledger" => Some((17.0, 11.0)),
"a0" => Some((33.1, 46.8)),
"a1" => Some((23.4, 33.1)),
"a2" => Some((16.54, 23.4)),
"a3" => Some((11.7, 16.54)),
"a4" => Some((8.27, 11.7)),
"a5" => Some((5.83, 8.27)),
"a6" => Some((4.13, 5.83)),
_ => None,
}
}
#[derive(Debug, Clone, Default)]
pub struct PageCloseOptions {
pub run_before_unload: Option<bool>,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct BrowserCloseOptions {
pub reason: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct GotoOptions {
pub wait_until: Option<String>,
pub timeout: Option<u64>,
pub referer: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrowserKind {
Chromium,
Firefox,
WebKit,
}
impl BrowserKind {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Chromium => "chromium",
Self::Firefox => "firefox",
Self::WebKit => "webkit",
}
}
#[must_use]
pub fn default_backend(self) -> crate::backend::BackendKind {
match self {
Self::Chromium => crate::backend::BackendKind::CdpPipe,
Self::Firefox => crate::backend::BackendKind::Bidi,
Self::WebKit => crate::backend::BackendKind::WebKit,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LaunchOptions {
pub headless: Option<bool>,
pub executable_path: Option<String>,
pub args: Vec<String>,
pub channel: Option<String>,
pub env: Option<rustc_hash::FxHashMap<String, String>>,
pub slow_mo: Option<u64>,
pub timeout: Option<u64>,
pub downloads_path: Option<std::path::PathBuf>,
pub ignore_default_args: Option<IgnoreDefaultArgs>,
pub handle_sighup: Option<bool>,
pub handle_sigint: Option<bool>,
pub handle_sigterm: Option<bool>,
pub chromium_sandbox: Option<bool>,
pub firefox_user_prefs: Option<rustc_hash::FxHashMap<String, serde_json::Value>>,
pub proxy: Option<ProxyConfig>,
pub traces_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IgnoreDefaultArgs {
All,
Some(Vec<String>),
}
#[derive(Debug, Clone, Default)]
pub struct ConnectOptions {
pub headers: Option<rustc_hash::FxHashMap<String, String>>,
pub slow_mo: Option<u64>,
pub timeout: Option<u64>,
pub expose_network: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ConnectOverCdpOptions {
pub headers: Option<rustc_hash::FxHashMap<String, String>>,
pub slow_mo: Option<u64>,
pub timeout: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct LaunchPersistentContextOptions {
pub launch: LaunchOptions,
pub context: BrowserContextOptions,
}
#[derive(Debug, Clone, Default)]
pub struct BrowserTypeOptions {
pub transport: Option<ChromiumTransport>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChromiumTransport {
Pipe,
Ws,
}
#[derive(Debug, Clone)]
pub struct LaunchPlan {
pub backend: crate::backend::BackendKind,
pub kind: BrowserKind,
pub headless: bool,
pub executable_path: Option<String>,
pub args: Vec<String>,
pub channel: Option<String>,
pub env: Option<rustc_hash::FxHashMap<String, String>>,
pub user_data_dir: Option<String>,
pub ws_endpoint: Option<String>,
pub auto_connect: Option<AutoConnectOptions>,
pub default_viewport: Option<ViewportConfig>,
pub slow_mo: Option<u64>,
pub timeout: Option<u64>,
pub downloads_path: Option<std::path::PathBuf>,
pub ignore_default_args: Option<IgnoreDefaultArgs>,
pub handle_sighup: Option<bool>,
pub handle_sigint: Option<bool>,
pub handle_sigterm: Option<bool>,
pub chromium_sandbox: Option<bool>,
pub firefox_user_prefs: Option<rustc_hash::FxHashMap<String, serde_json::Value>>,
pub proxy: Option<ProxyConfig>,
pub traces_dir: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone)]
pub struct AutoConnectOptions {
pub channel: String,
pub user_data_dir: Option<String>,
}
impl Default for LaunchPlan {
fn default() -> Self {
Self {
backend: crate::backend::BackendKind::CdpPipe,
kind: BrowserKind::Chromium,
headless: true,
executable_path: None,
args: Vec::new(),
channel: None,
env: None,
user_data_dir: None,
ws_endpoint: None,
auto_connect: None,
default_viewport: Some(ViewportConfig::default()),
slow_mo: None,
timeout: None,
downloads_path: None,
ignore_default_args: None,
handle_sighup: None,
handle_sigint: None,
handle_sigterm: None,
chromium_sandbox: None,
firefox_user_prefs: None,
proxy: None,
traces_dir: None,
}
}
}
impl LaunchPlan {
#[must_use]
pub fn from_public(kind: BrowserKind, transport: Option<ChromiumTransport>, opts: LaunchOptions) -> Self {
let backend = match (kind, transport) {
(BrowserKind::Chromium, Some(ChromiumTransport::Ws)) => crate::backend::BackendKind::CdpRaw,
_ => kind.default_backend(),
};
Self {
backend,
kind,
headless: opts.headless.unwrap_or(true),
executable_path: opts.executable_path,
args: opts.args,
channel: opts.channel,
env: opts.env,
user_data_dir: None,
ws_endpoint: None,
auto_connect: None,
default_viewport: Some(ViewportConfig::default()),
slow_mo: opts.slow_mo,
timeout: opts.timeout,
downloads_path: opts.downloads_path,
ignore_default_args: opts.ignore_default_args,
handle_sighup: opts.handle_sighup,
handle_sigint: opts.handle_sigint,
handle_sigterm: opts.handle_sigterm,
chromium_sandbox: opts.chromium_sandbox,
firefox_user_prefs: opts.firefox_user_prefs,
proxy: opts.proxy,
traces_dir: opts.traces_dir,
}
}
}
impl Default for ViewportConfig {
fn default() -> Self {
Self {
width: 1280,
height: 720,
device_scale_factor: 1.0,
is_mobile: false,
has_touch: false,
is_landscape: false,
}
}
}
#[derive(Debug, Clone)]
pub struct RecordVideoOptions {
pub dir: std::path::PathBuf,
pub size: Option<VideoSize>,
}
#[derive(Debug, Clone, Copy)]
pub struct VideoSize {
pub width: u32,
pub height: u32,
}
impl Default for VideoSize {
fn default() -> Self {
Self {
width: 800,
height: 450,
}
}
}
#[must_use]
pub fn construct_url_with_base(base: Option<&str>, given: &str) -> String {
if base.is_none() || given.contains("://") || given.starts_with("data:") || given.starts_with("about:") {
return given.to_string();
}
let base = base.unwrap_or("");
let (base_origin, base_path) = split_origin_and_path(base);
if given.starts_with('/') {
return format!("{base_origin}{given}");
}
let cut = base_path.rfind('/').map_or(0, |i| i + 1);
let kept = &base_path[..cut];
format!("{base_origin}{kept}{given}")
}
fn split_origin_and_path(url: &str) -> (&str, &str) {
let Some(scheme_end) = url.find("://") else {
return ("", url);
};
let rest_start = scheme_end + 3;
let rest = &url[rest_start..];
match rest.find('/') {
Some(path_start) => (&url[..rest_start + path_start], &rest[path_start..]),
None => (url, "/"),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Geolocation {
pub latitude: f64,
pub longitude: f64,
pub accuracy: f64,
}
impl Default for Geolocation {
fn default() -> Self {
Self {
latitude: 0.0,
longitude: 0.0,
accuracy: 0.0,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HttpCredentials {
pub username: String,
pub password: String,
pub origin: Option<String>,
pub send: Option<HttpCredentialsSend>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum HttpCredentialsSend {
#[default]
Unauthorized,
Always,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProxyConfig {
pub server: String,
pub bypass: Option<String>,
pub username: Option<String>,
pub password: Option<String>,
}
#[derive(Debug, Clone)]
pub struct RecordHarOptions {
pub path: std::path::PathBuf,
pub content: Option<RecordHarContent>,
pub mode: Option<RecordHarMode>,
pub omit_content: Option<bool>,
pub url_filter: Option<crate::url_matcher::UrlMatcher>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordHarContent {
Omit,
Embed,
Attach,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordHarMode {
Full,
Minimal,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum ViewportOption {
#[default]
Default,
Null,
Size { width: i64, height: i64 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScreenSize {
pub width: i64,
pub height: i64,
}
#[derive(Debug, Clone)]
pub enum StorageStateInput {
Path(std::path::PathBuf),
Inline(serde_json::Value),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ServiceWorkerPolicy {
#[default]
Allow,
Block,
}
#[derive(Debug, Clone, Default)]
pub struct BrowserContextOptions {
pub accept_downloads: Option<bool>,
pub base_url: Option<String>,
pub bypass_csp: Option<bool>,
pub color_scheme: MediaOverride,
pub contrast: MediaOverride,
pub device_scale_factor: Option<f64>,
pub extra_http_headers: Option<rustc_hash::FxHashMap<String, String>>,
pub forced_colors: MediaOverride,
pub geolocation: Option<Geolocation>,
pub has_touch: Option<bool>,
pub http_credentials: Option<HttpCredentials>,
pub ignore_https_errors: Option<bool>,
pub is_mobile: Option<bool>,
pub java_script_enabled: Option<bool>,
pub locale: Option<String>,
pub offline: Option<bool>,
pub permissions: Option<Vec<String>>,
pub proxy: Option<ProxyConfig>,
pub record_har: Option<RecordHarOptions>,
pub record_video: Option<RecordVideoOptions>,
pub reduced_motion: MediaOverride,
pub screen: Option<ScreenSize>,
pub service_workers: Option<ServiceWorkerPolicy>,
pub storage_state: Option<StorageStateInput>,
pub strict_selectors: Option<bool>,
pub timezone_id: Option<String>,
pub user_agent: Option<String>,
pub viewport: ViewportOption,
}
impl BrowserContextOptions {
#[must_use]
pub fn resolved_viewport(&self) -> Option<ViewportConfig> {
let (width, height) = match self.viewport {
ViewportOption::Null => return None,
ViewportOption::Default => (1280, 720),
ViewportOption::Size { width, height } => (width, height),
};
Some(ViewportConfig {
width,
height,
device_scale_factor: self.device_scale_factor.unwrap_or(1.0),
is_mobile: self.is_mobile.unwrap_or(false),
has_touch: self.has_touch.unwrap_or(false),
is_landscape: false,
})
}
#[must_use]
pub fn any_media_override(&self) -> bool {
self.color_scheme.is_specified()
|| self.reduced_motion.is_specified()
|| self.forced_colors.is_specified()
|| self.contrast.is_specified()
}
#[must_use]
pub fn as_emulate_media(&self) -> EmulateMediaOptions {
EmulateMediaOptions {
media: MediaOverride::Unchanged,
color_scheme: self.color_scheme.clone(),
reduced_motion: self.reduced_motion.clone(),
forced_colors: self.forced_colors.clone(),
contrast: self.contrast.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FrameSelector {
pub name: Option<String>,
pub url: Option<String>,
}
impl FrameSelector {
#[must_use]
pub fn by_name(name: impl Into<String>) -> Self {
Self {
name: Some(name.into()),
url: None,
}
}
#[must_use]
pub fn by_url(url: impl Into<String>) -> Self {
Self {
name: None,
url: Some(url.into()),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.name.is_none() && self.url.is_none()
}
}
impl From<&str> for FrameSelector {
fn from(name: &str) -> Self {
Self::by_name(name)
}
}
impl From<String> for FrameSelector {
fn from(name: String) -> Self {
Self::by_name(name)
}
}
impl From<&String> for FrameSelector {
fn from(name: &String) -> Self {
Self::by_name(name.clone())
}
}
#[cfg(test)]
mod pdf_option_tests {
use super::*;
#[test]
fn parses_pixel_suffix() {
assert_eq!(PdfSize::parse("100px").unwrap(), PdfSize::Pixels(100.0));
}
#[test]
fn parses_inch_suffix() {
assert_eq!(PdfSize::parse("8.5in").unwrap(), PdfSize::Inches(8.5));
}
#[test]
fn parses_cm_and_mm_suffixes() {
assert_eq!(PdfSize::parse("10cm").unwrap(), PdfSize::Centimeters(10.0));
assert_eq!(PdfSize::parse("5.5mm").unwrap(), PdfSize::Millimeters(5.5));
}
#[test]
fn parses_suffix_case_insensitively() {
assert_eq!(PdfSize::parse("8.5IN").unwrap(), PdfSize::Inches(8.5));
assert_eq!(PdfSize::parse("100Px").unwrap(), PdfSize::Pixels(100.0));
}
#[test]
fn bare_number_falls_back_to_pixels() {
assert_eq!(PdfSize::parse("42").unwrap(), PdfSize::Pixels(42.0));
}
#[test]
fn unknown_suffix_falls_back_to_pixels() {
assert!(PdfSize::parse("42em").is_err());
}
#[test]
fn invalid_number_is_rejected() {
assert!(PdfSize::parse("abcpx").is_err());
}
#[test]
fn short_input_takes_pixel_fallback() {
assert_eq!(PdfSize::parse("5").unwrap(), PdfSize::Pixels(5.0));
}
#[test]
fn pixels_convert_using_96_dpi() {
assert!((PdfSize::Pixels(96.0).to_inches() - 1.0).abs() < 1e-9);
}
#[test]
fn inches_are_identity() {
assert!((PdfSize::Inches(2.5).to_inches() - 2.5).abs() < 1e-9);
}
#[test]
fn centimeters_convert_using_table_constants() {
let expected = 10.0 * 37.8 / 96.0;
assert!((PdfSize::Centimeters(10.0).to_inches() - expected).abs() < 1e-9);
}
#[test]
fn millimeters_convert_using_table_constants() {
let expected = 25.0 * 3.78 / 96.0;
assert!((PdfSize::Millimeters(25.0).to_inches() - expected).abs() < 1e-9);
}
#[test]
fn paper_formats_return_canonical_sizes() {
assert_eq!(pdf_paper_format_size("Letter"), Some((8.5, 11.0)));
assert_eq!(pdf_paper_format_size("A4"), Some((8.27, 11.7)));
assert_eq!(pdf_paper_format_size("tabloid"), Some((11.0, 17.0)));
assert_eq!(pdf_paper_format_size("LEDGER"), Some((17.0, 11.0)));
}
#[test]
fn unknown_paper_format_returns_none() {
assert_eq!(pdf_paper_format_size("A99"), None);
assert_eq!(pdf_paper_format_size(""), None);
}
#[test]
fn default_pdf_options_has_no_overrides() {
let opts = PdfOptions::default();
assert!(opts.format.is_none());
assert!(opts.path.is_none());
assert!(opts.scale.is_none());
assert!(opts.display_header_footer.is_none());
assert!(opts.header_template.is_none());
assert!(opts.footer_template.is_none());
assert!(opts.print_background.is_none());
assert!(opts.landscape.is_none());
assert!(opts.page_ranges.is_none());
assert!(opts.width.is_none());
assert!(opts.height.is_none());
assert!(opts.margin.is_none());
assert!(opts.prefer_css_page_size.is_none());
assert!(opts.outline.is_none());
assert!(opts.tagged.is_none());
}
}
#[cfg(test)]
mod media_override_tests {
use super::*;
#[test]
fn default_is_unchanged() {
let o: MediaOverride = MediaOverride::default();
assert_eq!(o, MediaOverride::Unchanged);
assert!(!o.is_specified());
assert_eq!(o.as_value(), None);
}
#[test]
fn set_reports_value() {
let o = MediaOverride::Set("dark".into());
assert!(o.is_specified());
assert_eq!(o.as_value(), Some("dark"));
}
#[test]
fn disabled_is_specified_without_value() {
let o = MediaOverride::Disabled;
assert!(o.is_specified());
assert_eq!(o.as_value(), None);
}
#[test]
fn from_option_string_maps_some_to_set_and_none_to_unchanged() {
let set: MediaOverride = Some("dark".to_string()).into();
assert_eq!(set, MediaOverride::Set("dark".into()));
let unch: MediaOverride = None.into();
assert_eq!(unch, MediaOverride::Unchanged);
}
#[test]
fn default_emulate_media_is_all_unchanged() {
let o = EmulateMediaOptions::default();
assert_eq!(o.media, MediaOverride::Unchanged);
assert_eq!(o.color_scheme, MediaOverride::Unchanged);
assert_eq!(o.reduced_motion, MediaOverride::Unchanged);
assert_eq!(o.forced_colors, MediaOverride::Unchanged);
assert_eq!(o.contrast, MediaOverride::Unchanged);
}
}
#[cfg(test)]
mod drag_option_tests {
use super::*;
#[test]
fn default_drag_options_has_no_overrides() {
let opts = DragAndDropOptions::default();
assert!(opts.force.is_none());
assert!(opts.no_wait_after.is_none());
assert!(opts.source_position.is_none());
assert!(opts.target_position.is_none());
assert!(opts.steps.is_none());
assert!(opts.strict.is_none());
assert!(opts.timeout.is_none());
assert!(opts.trial.is_none());
}
#[test]
fn drag_options_carry_every_field() {
let opts = DragAndDropOptions {
force: Some(true),
no_wait_after: Some(false),
source_position: Some(Point { x: 10.0, y: 20.0 }),
target_position: Some(Point { x: 30.0, y: 40.0 }),
steps: Some(5),
strict: Some(true),
timeout: Some(2_000),
trial: Some(true),
};
assert_eq!(opts.force, Some(true));
assert_eq!(opts.no_wait_after, Some(false));
assert_eq!(opts.source_position, Some(Point { x: 10.0, y: 20.0 }));
assert_eq!(opts.target_position, Some(Point { x: 30.0, y: 40.0 }));
assert_eq!(opts.steps, Some(5));
assert_eq!(opts.strict, Some(true));
assert_eq!(opts.timeout, Some(2_000));
assert_eq!(opts.trial, Some(true));
}
#[test]
fn point_default_is_origin() {
assert_eq!(Point::default(), Point { x: 0.0, y: 0.0 });
}
}
#[cfg(test)]
mod init_script_tests {
use super::*;
use serde_json::json;
#[test]
fn function_with_undefined_arg_renders_literal_undefined() {
let src = evaluation_script(
InitScriptSource::Function {
body: "(x) => x + 1".to_string(),
},
None,
)
.unwrap();
assert_eq!(src, "((x) => x + 1)(undefined)");
}
#[test]
fn function_with_null_arg_renders_literal_null() {
let src = evaluation_script(
InitScriptSource::Function {
body: "(x) => x".to_string(),
},
Some(&serde_json::Value::Null),
)
.unwrap();
assert_eq!(src, "((x) => x)(null)");
}
#[test]
fn function_with_object_arg_renders_json() {
let arg = json!({ "answer": 42, "nested": [1, 2, 3] });
let src = evaluation_script(
InitScriptSource::Function {
body: "function (o) { window.x = o; }".to_string(),
},
Some(&arg),
)
.unwrap();
assert_eq!(
src,
r#"(function (o) { window.x = o; })({"answer":42,"nested":[1,2,3]})"#
);
}
#[test]
fn source_without_arg_passes_through_verbatim() {
let src = evaluation_script(InitScriptSource::Source("window.x = 1".into()), None).unwrap();
assert_eq!(src, "window.x = 1");
}
#[test]
fn source_with_arg_errors() {
let err = evaluation_script(InitScriptSource::Source("window.x = 1".into()), Some(&json!(42))).unwrap_err();
assert!(
err.to_string().contains("Cannot evaluate a string with arguments"),
"unexpected error: {err}"
);
}
#[test]
fn content_with_arg_errors() {
let err = evaluation_script(InitScriptSource::Content("1".into()), Some(&json!(0))).unwrap_err();
assert!(err.to_string().contains("Cannot evaluate a string with arguments"));
}
#[test]
fn path_with_arg_errors() {
let err = evaluation_script(
InitScriptSource::Path(std::path::PathBuf::from("/nope")),
Some(&json!(0)),
)
.unwrap_err();
assert!(err.to_string().contains("Cannot evaluate a string with arguments"));
}
#[test]
fn path_reads_file_and_appends_source_url() {
let dir = std::env::temp_dir();
let path = dir.join(format!("fd-init-script-{}.js", std::process::id()));
std::fs::write(&path, "window.__fromFile = 7;").unwrap();
let src = evaluation_script(InitScriptSource::Path(path.clone()), None).unwrap();
let expected = format!("window.__fromFile = 7;\n//# sourceURL={}", path.display());
assert_eq!(src, expected);
let _ = std::fs::remove_file(path);
}
#[test]
fn path_missing_errors() {
let missing = std::path::PathBuf::from("/definitely/not/a/real/path/x.js");
let err = evaluation_script(InitScriptSource::Path(missing), None).unwrap_err();
assert!(matches!(err, crate::error::FerriError::Io(_)), "unexpected: {err}");
}
#[test]
fn content_passes_through_verbatim() {
let src = evaluation_script(InitScriptSource::Content("let z = 2;".into()), None).unwrap();
assert_eq!(src, "let z = 2;");
}
}
#[cfg(test)]
mod click_option_tests {
use super::*;
#[test]
fn mouse_button_parse_round_trip() {
assert_eq!(MouseButton::parse("left"), Some(MouseButton::Left));
assert_eq!(MouseButton::parse("right"), Some(MouseButton::Right));
assert_eq!(MouseButton::parse("middle"), Some(MouseButton::Middle));
assert_eq!(MouseButton::parse("garbage"), None);
assert_eq!(MouseButton::Left.as_cdp(), "left");
assert_eq!(MouseButton::Right.as_cdp(), "right");
assert_eq!(MouseButton::Middle.as_cdp(), "middle");
assert_eq!(MouseButton::Left.as_bidi(), 0);
assert_eq!(MouseButton::Middle.as_bidi(), 1);
assert_eq!(MouseButton::Right.as_bidi(), 2);
assert_eq!(MouseButton::Left.as_webkit(), 0);
assert_eq!(MouseButton::Right.as_webkit(), 1);
assert_eq!(MouseButton::Middle.as_webkit(), 2);
}
#[test]
fn modifier_parse_and_bits() {
assert_eq!(Modifier::parse("Alt"), Some(Modifier::Alt));
assert_eq!(Modifier::parse("Control"), Some(Modifier::Control));
assert_eq!(Modifier::parse("Meta"), Some(Modifier::Meta));
assert_eq!(Modifier::parse("Shift"), Some(Modifier::Shift));
assert_eq!(Modifier::parse("ControlOrMeta"), Some(Modifier::ControlOrMeta));
assert_eq!(Modifier::parse("garbage"), None);
assert_eq!(Modifier::Alt.cdp_bit(), 1);
assert_eq!(Modifier::Control.cdp_bit(), 2);
assert_eq!(Modifier::Meta.cdp_bit(), 4);
assert_eq!(Modifier::Shift.cdp_bit(), 8);
if cfg!(target_os = "macos") {
assert_eq!(Modifier::ControlOrMeta.cdp_bit(), 4);
assert_eq!(Modifier::ControlOrMeta.key_name(), "Meta");
assert_eq!(Modifier::ControlOrMeta.key_code(), "MetaLeft");
} else {
assert_eq!(Modifier::ControlOrMeta.cdp_bit(), 2);
assert_eq!(Modifier::ControlOrMeta.key_name(), "Control");
assert_eq!(Modifier::ControlOrMeta.key_code(), "ControlLeft");
}
}
#[test]
fn modifiers_bitmask_folds_multiple() {
assert_eq!(modifiers_bitmask(&[]), 0);
assert_eq!(modifiers_bitmask(&[Modifier::Shift]), 8);
assert_eq!(
modifiers_bitmask(&[Modifier::Alt, Modifier::Control, Modifier::Meta, Modifier::Shift]),
15
);
assert_eq!(modifiers_bitmask(&[Modifier::Shift, Modifier::Shift]), 8);
}
#[test]
fn click_options_default_values() {
let opts = ClickOptions::default();
assert_eq!(opts.resolved_button(), MouseButton::Left);
assert_eq!(opts.resolved_click_count(), 1);
assert_eq!(opts.resolved_delay_ms(), 0);
assert_eq!(opts.resolved_steps(), 1);
assert!(!opts.is_force());
assert!(!opts.is_trial());
assert!(opts.modifiers.is_empty());
assert!(opts.position.is_none());
assert!(opts.timeout.is_none());
assert!(opts.no_wait_after.is_none());
}
#[test]
fn click_options_resolved_helpers_use_overrides() {
let opts = ClickOptions {
button: Some(MouseButton::Right),
click_count: Some(2),
delay: Some(150),
steps: Some(5),
force: Some(true),
trial: Some(true),
..Default::default()
};
assert_eq!(opts.resolved_button(), MouseButton::Right);
assert_eq!(opts.resolved_click_count(), 2);
assert_eq!(opts.resolved_delay_ms(), 150);
assert_eq!(opts.resolved_steps(), 5);
assert!(opts.is_force());
assert!(opts.is_trial());
}
#[test]
fn click_options_steps_coerces_zero_to_one() {
let opts = ClickOptions {
steps: Some(0),
..Default::default()
};
assert_eq!(opts.resolved_steps(), 1);
}
}