mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
* feat(emulate): add mobile device emulation via CDP Emulation domain Add `bsk emulate` so agents can debug mobile page behaviour by emulating a device environment on a session tab: - Viewport metrics (Emulation.setDeviceMetricsOverride), User-Agent (Emulation.setUserAgentOverride, optional accept-language and UA client-hints metadata), and touch (Emulation.setTouchEmulationEnabled with maxTouchPoints). - Seven CLI-side device presets (iphone-14, iphone-14-pro-max, iphone-se, pixel-7, galaxy-s23, ipad-mini, galaxy-tab-s8) via --device; manual flags (--width/--height/--dpr/--mobile/--ua/ --accept-language/--touch/--max-touch-points) work standalone or override individual preset fields. - `bsk emulate --off` clears every override (clearDeviceMetricsOverride + touch disabled + empty UA override) and restores the real environment. - Overrides are per-tab (CDP per-target) and not inherited by new tabs; the result JSON carries a note reminding agents to re-apply. Wire: new tool.emulate method end-to-end (bsk-protocol types + JSON schemas + Method::ToolEmulate classified BrowserMutation, daemon forwarding, CLI subcommand, extension tool handler + ChromiumCdp Emulation wrappers, dispatcher registration), docs in both SKILL.md copies, and unit tests on every layer. * refine(emulate): merge per-tab state and align preset override semantics - extension: remember the applied emulation state per tab and merge each new request onto it field by field before applying, so a later --width/--height no longer resets an earlier preset's dpr/mobile; --off clears the stored state along with the CDP overrides - extension: cdp_failed errors now note that already-applied overrides were not rolled back and can be reset with --off - cli: validate width/height pairing and metric requirements on the merged preset+flags result, so --device iphone-14 --width 500 keeps the preset height - cli: add --no-mobile/--no-touch (conflicting with --mobile/--touch) to turn preset mobile/touch fields back off - docs: describe the merge behaviour, --no-mobile/--no-touch and --off state clearing in both SKILL.md copies
729 lines
22 KiB
Rust
729 lines
22 KiB
Rust
//! `bsk emulate` — emulate a mobile device environment on a tab via the
|
|
//! CDP Emulation domain (viewport metrics, user agent, touch), for
|
|
//! mobile page debugging.
|
|
//!
|
|
//! Device presets are resolved here, CLI-side: the extension only
|
|
//! executes the concrete override parameters it receives, so presets are
|
|
//! not duplicated in TypeScript.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use anyhow::Context;
|
|
use bsk_protocol::tools::{EmulateOverrides, EmulateParams, EmulateResult};
|
|
use bsk_protocol::{ErrorCode, Method, RpcError};
|
|
use clap::Args;
|
|
|
|
use crate::cli::TOOL_IPC_TIMEOUT;
|
|
use crate::cli::ensure_daemon::ensure_daemon;
|
|
use crate::cli::error::{CliError, Format};
|
|
|
|
/// One built-in device preset: viewport metrics + UA + touch applied in
|
|
/// a single `--device` invocation.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub struct DevicePreset {
|
|
pub name: &'static str,
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub device_scale_factor: f64,
|
|
pub mobile: bool,
|
|
pub user_agent: &'static str,
|
|
pub max_touch_points: u32,
|
|
}
|
|
|
|
const IPHONE_UA: &str = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
|
const IPAD_UA: &str = "Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
|
|
|
/// Built-in device presets for `--device`. Keep names lowercase-kebab;
|
|
/// they are the public CLI identifiers.
|
|
pub const DEVICE_PRESETS: &[DevicePreset] = &[
|
|
DevicePreset {
|
|
name: "iphone-14",
|
|
width: 390,
|
|
height: 844,
|
|
device_scale_factor: 3.0,
|
|
mobile: true,
|
|
user_agent: IPHONE_UA,
|
|
max_touch_points: 5,
|
|
},
|
|
DevicePreset {
|
|
name: "iphone-14-pro-max",
|
|
width: 430,
|
|
height: 932,
|
|
device_scale_factor: 3.0,
|
|
mobile: true,
|
|
user_agent: IPHONE_UA,
|
|
max_touch_points: 5,
|
|
},
|
|
DevicePreset {
|
|
name: "iphone-se",
|
|
width: 375,
|
|
height: 667,
|
|
device_scale_factor: 2.0,
|
|
mobile: true,
|
|
user_agent: IPHONE_UA,
|
|
max_touch_points: 5,
|
|
},
|
|
DevicePreset {
|
|
name: "pixel-7",
|
|
width: 412,
|
|
height: 915,
|
|
device_scale_factor: 2.625,
|
|
mobile: true,
|
|
user_agent: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36",
|
|
max_touch_points: 5,
|
|
},
|
|
DevicePreset {
|
|
name: "galaxy-s23",
|
|
width: 360,
|
|
height: 780,
|
|
device_scale_factor: 3.0,
|
|
mobile: true,
|
|
user_agent: "Mozilla/5.0 (Linux; Android 14; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Mobile Safari/537.36",
|
|
max_touch_points: 5,
|
|
},
|
|
DevicePreset {
|
|
name: "ipad-mini",
|
|
width: 744,
|
|
height: 1133,
|
|
device_scale_factor: 2.0,
|
|
mobile: true,
|
|
user_agent: IPAD_UA,
|
|
max_touch_points: 5,
|
|
},
|
|
DevicePreset {
|
|
name: "galaxy-tab-s8",
|
|
width: 800,
|
|
height: 1280,
|
|
device_scale_factor: 2.0,
|
|
mobile: true,
|
|
user_agent: "Mozilla/5.0 (Linux; Android 14; SM-X700) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.43 Safari/537.36",
|
|
max_touch_points: 5,
|
|
},
|
|
];
|
|
|
|
/// Comma-separated preset names for error messages.
|
|
pub fn preset_names() -> String {
|
|
DEVICE_PRESETS
|
|
.iter()
|
|
.map(|p| p.name)
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
}
|
|
|
|
/// Look up a preset by its CLI name.
|
|
pub fn find_preset(name: &str) -> Option<&'static DevicePreset> {
|
|
DEVICE_PRESETS.iter().find(|p| p.name == name)
|
|
}
|
|
|
|
#[derive(Debug, Clone, Args)]
|
|
pub struct EmulateArgs {
|
|
/// Session id (must be active).
|
|
#[arg(long)]
|
|
pub session: String,
|
|
|
|
/// Target tab. Defaults to the Agent Window's active tab.
|
|
#[arg(long = "tab-id")]
|
|
pub tab_id: Option<i64>,
|
|
|
|
/// Built-in device preset (iphone-14, iphone-14-pro-max, iphone-se,
|
|
/// pixel-7, galaxy-s23, ipad-mini, galaxy-tab-s8). Manual flags
|
|
/// override individual preset fields.
|
|
#[arg(long)]
|
|
pub device: Option<String>,
|
|
|
|
/// Viewport width in CSS pixels (requires --height unless --device
|
|
/// supplies it).
|
|
#[arg(long, value_parser = viewport_dimension)]
|
|
pub width: Option<u32>,
|
|
|
|
/// Viewport height in CSS pixels (requires --width unless --device
|
|
/// supplies it).
|
|
#[arg(long, value_parser = viewport_dimension)]
|
|
pub height: Option<u32>,
|
|
|
|
/// Device pixel ratio (requires --width/--height without --device).
|
|
#[arg(long, value_parser = device_pixel_ratio)]
|
|
pub dpr: Option<f64>,
|
|
|
|
/// Emulate a mobile viewport (requires --width/--height without
|
|
/// --device).
|
|
#[arg(long)]
|
|
pub mobile: bool,
|
|
|
|
/// Turn a preset's mobile viewport flag back off.
|
|
#[arg(long, conflicts_with = "mobile")]
|
|
pub no_mobile: bool,
|
|
|
|
/// User-Agent override string.
|
|
#[arg(long)]
|
|
pub ua: Option<String>,
|
|
|
|
/// Accept-Language header override (requires --ua or --device).
|
|
#[arg(long = "accept-language")]
|
|
pub accept_language: Option<String>,
|
|
|
|
/// Enable touch emulation (combines with --device or manual flags).
|
|
#[arg(long)]
|
|
pub touch: bool,
|
|
|
|
/// Turn a preset's touch emulation back off.
|
|
#[arg(long, conflicts_with = "touch")]
|
|
pub no_touch: bool,
|
|
|
|
/// Touch points reported while touch emulation is enabled (implies
|
|
/// --touch).
|
|
#[arg(long = "max-touch-points", value_parser = touch_points)]
|
|
pub max_touch_points: Option<u32>,
|
|
|
|
/// Clear all emulation overrides and restore the tab's real
|
|
/// environment. Mutually exclusive with every other option.
|
|
#[arg(long)]
|
|
pub off: bool,
|
|
}
|
|
|
|
/// Parse a `--width` / `--height` viewport dimension (CSS pixels).
|
|
fn viewport_dimension(s: &str) -> Result<u32, String> {
|
|
let value: u32 = s
|
|
.parse()
|
|
.map_err(|_| format!("invalid viewport dimension {s:?}: expected a positive integer"))?;
|
|
if (1..=7680).contains(&value) {
|
|
Ok(value)
|
|
} else {
|
|
Err(format!(
|
|
"viewport dimension {value} out of range (1..=7680)"
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Parse a `--dpr` device pixel ratio.
|
|
fn device_pixel_ratio(s: &str) -> Result<f64, String> {
|
|
let value: f64 = s
|
|
.parse()
|
|
.map_err(|_| format!("invalid device pixel ratio {s:?}: expected a number"))?;
|
|
if value.is_finite() && value > 0.0 {
|
|
Ok(value)
|
|
} else {
|
|
Err(format!(
|
|
"device pixel ratio {value} out of range (must be positive and finite)"
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Parse a `--max-touch-points` value.
|
|
fn touch_points(s: &str) -> Result<u32, String> {
|
|
let value: u32 = s
|
|
.parse()
|
|
.map_err(|_| format!("invalid max touch points {s:?}: expected a positive integer"))?;
|
|
if value >= 1 {
|
|
Ok(value)
|
|
} else {
|
|
Err("max touch points must be at least 1".to_string())
|
|
}
|
|
}
|
|
|
|
fn invalid_params(message: &str) -> CliError {
|
|
CliError::from_rpc(RpcError {
|
|
code: ErrorCode::InvalidParams,
|
|
message: message.to_string(),
|
|
data: None,
|
|
})
|
|
}
|
|
|
|
/// Validate the argument combination and build the wire params. Pure so
|
|
/// it can be unit-tested without a daemon.
|
|
///
|
|
/// The preset (if any) forms the base, manual flags override individual
|
|
/// fields, and only the merged result is validated — so a preset can
|
|
/// supply the dimension a lone `--width`/`--height` lacks.
|
|
pub fn build_params(args: &EmulateArgs) -> Result<EmulateParams, CliError> {
|
|
let has_manual = args.width.is_some()
|
|
|| args.height.is_some()
|
|
|| args.dpr.is_some()
|
|
|| args.mobile
|
|
|| args.no_mobile
|
|
|| args.ua.is_some()
|
|
|| args.accept_language.is_some()
|
|
|| args.touch
|
|
|| args.no_touch
|
|
|| args.max_touch_points.is_some();
|
|
|
|
if args.off {
|
|
if args.device.is_some() || has_manual {
|
|
return Err(invalid_params(
|
|
"--off cannot be combined with --device or manual emulation options",
|
|
));
|
|
}
|
|
return Ok(EmulateParams {
|
|
session_id: args.session.clone(),
|
|
tab_id: args.tab_id,
|
|
off: Some(true),
|
|
overrides: None,
|
|
});
|
|
}
|
|
|
|
if args.device.is_none() && !has_manual {
|
|
return Err(invalid_params(&format!(
|
|
"nothing to do: pass --device <preset> ({}), manual options (--width/--height/--dpr/--mobile/--ua/--touch), or --off",
|
|
preset_names()
|
|
)));
|
|
}
|
|
|
|
let mut overrides = match args.device.as_deref() {
|
|
Some(name) => {
|
|
let preset = find_preset(name).ok_or_else(|| {
|
|
invalid_params(&format!(
|
|
"unknown device preset {name:?} (available: {})",
|
|
preset_names()
|
|
))
|
|
})?;
|
|
EmulateOverrides {
|
|
width: Some(preset.width),
|
|
height: Some(preset.height),
|
|
device_scale_factor: Some(preset.device_scale_factor),
|
|
mobile: Some(preset.mobile),
|
|
user_agent: Some(preset.user_agent.to_string()),
|
|
accept_language: None,
|
|
user_agent_metadata: None,
|
|
touch: Some(true),
|
|
max_touch_points: Some(preset.max_touch_points),
|
|
}
|
|
}
|
|
None => EmulateOverrides::default(),
|
|
};
|
|
|
|
if let Some(w) = args.width {
|
|
overrides.width = Some(w);
|
|
}
|
|
if let Some(h) = args.height {
|
|
overrides.height = Some(h);
|
|
}
|
|
if let Some(dpr) = args.dpr {
|
|
overrides.device_scale_factor = Some(dpr);
|
|
}
|
|
if args.mobile {
|
|
overrides.mobile = Some(true);
|
|
}
|
|
if args.no_mobile {
|
|
overrides.mobile = Some(false);
|
|
}
|
|
if let Some(ua) = &args.ua {
|
|
overrides.user_agent = Some(ua.clone());
|
|
}
|
|
if let Some(accept_language) = &args.accept_language {
|
|
overrides.accept_language = Some(accept_language.clone());
|
|
}
|
|
if args.touch || args.max_touch_points.is_some() {
|
|
overrides.touch = Some(true);
|
|
}
|
|
if args.no_touch {
|
|
overrides.touch = Some(false);
|
|
}
|
|
if let Some(max_touch_points) = args.max_touch_points {
|
|
overrides.max_touch_points = Some(max_touch_points);
|
|
}
|
|
|
|
// Cross-field checks run on the merged result.
|
|
match (overrides.width, overrides.height) {
|
|
(Some(_), None) | (None, Some(_)) => {
|
|
return Err(invalid_params(
|
|
"--width and --height must be provided together",
|
|
));
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if overrides.width.is_none()
|
|
&& (overrides.device_scale_factor.is_some() || overrides.mobile.is_some())
|
|
{
|
|
return Err(invalid_params(
|
|
"--dpr/--mobile/--no-mobile require --width/--height (viewport metrics need dimensions)",
|
|
));
|
|
}
|
|
|
|
if overrides.accept_language.is_some() && overrides.user_agent.is_none() {
|
|
return Err(invalid_params("--accept-language requires --ua"));
|
|
}
|
|
|
|
Ok(EmulateParams {
|
|
session_id: args.session.clone(),
|
|
tab_id: args.tab_id,
|
|
off: None,
|
|
overrides: Some(overrides),
|
|
})
|
|
}
|
|
|
|
pub fn dispatch(args: EmulateArgs, format: Format) -> Result<(), CliError> {
|
|
let device = args.device.clone();
|
|
let params = build_params(&args)?;
|
|
let info = ensure_daemon().context("ensure daemon is running")?;
|
|
let reply = call(info.sock_path, params)?;
|
|
render(&reply, device.as_deref(), format)
|
|
}
|
|
|
|
fn call(sock: PathBuf, params: EmulateParams) -> Result<EmulateResult, CliError> {
|
|
crate::cli::business_rpc::call::<EmulateParams, EmulateResult>(
|
|
sock,
|
|
"emulate",
|
|
Method::ToolEmulate,
|
|
Some(params),
|
|
TOOL_IPC_TIMEOUT,
|
|
)
|
|
}
|
|
|
|
fn render(reply: &EmulateResult, device: Option<&str>, format: Format) -> Result<(), CliError> {
|
|
match format {
|
|
Format::Json => {
|
|
let json = serde_json::to_string_pretty(reply)
|
|
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
|
|
println!("{json}");
|
|
}
|
|
Format::Human => {
|
|
if reply.cleared {
|
|
println!("tab={} emulation cleared", reply.tab_id);
|
|
} else {
|
|
print!("tab={} emulation applied", reply.tab_id);
|
|
if let Some(device) = device {
|
|
print!(" device={device}");
|
|
}
|
|
if let Some(applied) = &reply.applied {
|
|
if let (Some(w), Some(h)) = (applied.width, applied.height) {
|
|
print!(" viewport={w}x{h}");
|
|
if let Some(dpr) = applied.device_scale_factor {
|
|
print!("@{dpr}x");
|
|
}
|
|
if applied.mobile == Some(true) {
|
|
print!(" mobile");
|
|
}
|
|
}
|
|
if applied.user_agent.is_some() {
|
|
print!(" ua");
|
|
}
|
|
if applied.touch == Some(true) {
|
|
print!(" touch");
|
|
}
|
|
}
|
|
println!();
|
|
}
|
|
if let Some(note) = &reply.note {
|
|
println!("note: {note}");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn base_args() -> EmulateArgs {
|
|
EmulateArgs {
|
|
session: "s1".into(),
|
|
tab_id: None,
|
|
device: None,
|
|
width: None,
|
|
height: None,
|
|
dpr: None,
|
|
mobile: false,
|
|
no_mobile: false,
|
|
ua: None,
|
|
accept_language: None,
|
|
touch: false,
|
|
no_touch: false,
|
|
max_touch_points: None,
|
|
off: false,
|
|
}
|
|
}
|
|
|
|
fn build(args: &EmulateArgs) -> EmulateParams {
|
|
build_params(args).expect("build_params should succeed")
|
|
}
|
|
|
|
#[test]
|
|
fn preset_alone_applies_full_environment() {
|
|
let args = EmulateArgs {
|
|
device: Some("iphone-14".into()),
|
|
..base_args()
|
|
};
|
|
let params = build(&args);
|
|
let o = params.overrides.unwrap();
|
|
assert_eq!(o.width, Some(390));
|
|
assert_eq!(o.height, Some(844));
|
|
assert_eq!(o.device_scale_factor, Some(3.0));
|
|
assert_eq!(o.mobile, Some(true));
|
|
assert!(o.user_agent.unwrap().contains("iPhone"));
|
|
assert_eq!(o.touch, Some(true));
|
|
assert_eq!(o.max_touch_points, Some(5));
|
|
assert!(params.off.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn every_preset_is_applicable() {
|
|
assert_eq!(DEVICE_PRESETS.len(), 7);
|
|
for preset in DEVICE_PRESETS {
|
|
let args = EmulateArgs {
|
|
device: Some(preset.name.into()),
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.width, Some(preset.width));
|
|
assert_eq!(o.height, Some(preset.height));
|
|
assert!(o.user_agent.is_some());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn manual_flags_override_preset_fields() {
|
|
let args = EmulateArgs {
|
|
device: Some("pixel-7".into()),
|
|
width: Some(500),
|
|
height: Some(900),
|
|
dpr: Some(2.0),
|
|
ua: Some("custom-ua".into()),
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.width, Some(500));
|
|
assert_eq!(o.height, Some(900));
|
|
assert_eq!(o.device_scale_factor, Some(2.0));
|
|
assert_eq!(o.user_agent.as_deref(), Some("custom-ua"));
|
|
// Untouched preset fields survive.
|
|
assert_eq!(o.mobile, Some(true));
|
|
assert_eq!(o.touch, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_only_viewport_and_ua() {
|
|
let args = EmulateArgs {
|
|
width: Some(390),
|
|
height: Some(844),
|
|
dpr: Some(3.0),
|
|
mobile: true,
|
|
ua: Some("Mozilla/5.0 (iPhone…)".into()),
|
|
touch: true,
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.width, Some(390));
|
|
assert_eq!(o.device_scale_factor, Some(3.0));
|
|
assert_eq!(o.mobile, Some(true));
|
|
assert_eq!(o.touch, Some(true));
|
|
assert_eq!(o.max_touch_points, None);
|
|
}
|
|
|
|
#[test]
|
|
fn touch_alone_is_valid() {
|
|
let args = EmulateArgs {
|
|
touch: true,
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.touch, Some(true));
|
|
assert!(o.width.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn max_touch_points_implies_touch() {
|
|
let args = EmulateArgs {
|
|
max_touch_points: Some(5),
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.touch, Some(true));
|
|
assert_eq!(o.max_touch_points, Some(5));
|
|
}
|
|
|
|
#[test]
|
|
fn off_builds_clear_params() {
|
|
let args = EmulateArgs {
|
|
off: true,
|
|
..base_args()
|
|
};
|
|
let params = build(&args);
|
|
assert_eq!(params.off, Some(true));
|
|
assert!(params.overrides.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn off_is_exclusive() {
|
|
for args in [
|
|
EmulateArgs {
|
|
off: true,
|
|
device: Some("iphone-14".into()),
|
|
..base_args()
|
|
},
|
|
EmulateArgs {
|
|
off: true,
|
|
touch: true,
|
|
..base_args()
|
|
},
|
|
EmulateArgs {
|
|
off: true,
|
|
width: Some(390),
|
|
height: Some(844),
|
|
..base_args()
|
|
},
|
|
] {
|
|
let err = build_params(&args).unwrap_err();
|
|
assert!(err.to_string().contains("--off"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_invocation_is_rejected_with_guidance() {
|
|
let err = build_params(&base_args()).unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("--device"));
|
|
assert!(msg.contains("--off"));
|
|
assert!(msg.contains("iphone-14"));
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_preset_lists_available() {
|
|
let args = EmulateArgs {
|
|
device: Some("nokia-3310".into()),
|
|
..base_args()
|
|
};
|
|
let err = build_params(&args).unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains("nokia-3310"));
|
|
assert!(msg.contains("iphone-14"));
|
|
}
|
|
|
|
#[test]
|
|
fn width_height_must_pair() {
|
|
for args in [
|
|
EmulateArgs {
|
|
width: Some(390),
|
|
..base_args()
|
|
},
|
|
EmulateArgs {
|
|
height: Some(844),
|
|
..base_args()
|
|
},
|
|
] {
|
|
let err = build_params(&args).unwrap_err();
|
|
assert!(err.to_string().contains("together"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dpr_and_mobile_require_dimensions_without_preset() {
|
|
for args in [
|
|
EmulateArgs {
|
|
dpr: Some(3.0),
|
|
..base_args()
|
|
},
|
|
EmulateArgs {
|
|
mobile: true,
|
|
..base_args()
|
|
},
|
|
] {
|
|
let err = build_params(&args).unwrap_err();
|
|
assert!(err.to_string().contains("--width/--height"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dpr_without_dimensions_is_fine_with_preset() {
|
|
let args = EmulateArgs {
|
|
device: Some("iphone-se".into()),
|
|
dpr: Some(2.5),
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.device_scale_factor, Some(2.5));
|
|
assert_eq!(o.width, Some(375));
|
|
}
|
|
|
|
#[test]
|
|
fn preset_supplies_the_missing_dimension() {
|
|
// Pairing is validated on the merged result, so a lone --width
|
|
// keeps the preset's height instead of erroring out.
|
|
let args = EmulateArgs {
|
|
device: Some("iphone-14".into()),
|
|
width: Some(500),
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.width, Some(500));
|
|
assert_eq!(o.height, Some(844));
|
|
// Same for a lone --height.
|
|
let args = EmulateArgs {
|
|
device: Some("iphone-14".into()),
|
|
height: Some(900),
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.width, Some(390));
|
|
assert_eq!(o.height, Some(900));
|
|
}
|
|
|
|
#[test]
|
|
fn no_mobile_and_no_touch_override_preset_fields() {
|
|
let args = EmulateArgs {
|
|
device: Some("iphone-14".into()),
|
|
no_mobile: true,
|
|
no_touch: true,
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.mobile, Some(false));
|
|
assert_eq!(o.touch, Some(false));
|
|
// Untouched preset fields survive.
|
|
assert_eq!(o.width, Some(390));
|
|
assert_eq!(o.max_touch_points, Some(5));
|
|
}
|
|
|
|
#[test]
|
|
fn no_mobile_without_dimensions_is_rejected() {
|
|
// --no-mobile still maps to the mobile field, which needs a
|
|
// viewport without a preset.
|
|
let args = EmulateArgs {
|
|
no_mobile: true,
|
|
..base_args()
|
|
};
|
|
let err = build_params(&args).unwrap_err();
|
|
assert!(err.to_string().contains("--width/--height"));
|
|
}
|
|
|
|
#[test]
|
|
fn no_touch_alone_is_valid() {
|
|
let args = EmulateArgs {
|
|
no_touch: true,
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.touch, Some(false));
|
|
assert!(o.width.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn accept_language_requires_ua() {
|
|
let args = EmulateArgs {
|
|
accept_language: Some("zh-CN".into()),
|
|
..base_args()
|
|
};
|
|
let err = build_params(&args).unwrap_err();
|
|
assert!(err.to_string().contains("--ua"));
|
|
// …but pairs fine with a preset (which carries a UA).
|
|
let args = EmulateArgs {
|
|
device: Some("iphone-14".into()),
|
|
accept_language: Some("zh-CN".into()),
|
|
..base_args()
|
|
};
|
|
let o = build(&args).overrides.unwrap();
|
|
assert_eq!(o.accept_language.as_deref(), Some("zh-CN"));
|
|
}
|
|
|
|
#[test]
|
|
fn preset_names_are_unique_and_listed() {
|
|
let names = preset_names();
|
|
for preset in DEVICE_PRESETS {
|
|
assert!(names.contains(preset.name));
|
|
assert_eq!(find_preset(preset.name), Some(preset));
|
|
}
|
|
assert!(find_preset("IPHONE-14").is_none());
|
|
}
|
|
}
|