use ff_rdp_core::WebConsoleActor;
use serde_json::{Value, json};
use crate::cli::args::Cli;
use crate::error::AppError;
use crate::hints::{HintContext, HintSource};
use crate::output;
use crate::output_pipeline::OutputPipeline;
use super::connect_tab::connect_and_get_target;
use super::js_helpers::resolve_result;
const RESPONSIVE_JS_TEMPLATE: &str = r"(function() {
var selectors = __SELECTORS__;
var vw = document.documentElement.offsetWidth || window.innerWidth;
var vh = window.innerHeight || document.documentElement.clientHeight;
var elements = [];
for (var si = 0; si < selectors.length; si++) {
var sel = selectors[si];
var els = document.querySelectorAll(sel);
for (var ei = 0; ei < els.length; ei++) {
var el = els[ei];
var r = el.getBoundingClientRect();
var cs = window.getComputedStyle(el);
var rect = {
x: Math.round(r.x * 10) / 10,
y: Math.round(r.y * 10) / 10,
width: Math.round(r.width * 10) / 10,
height: Math.round(r.height * 10) / 10,
top: Math.round(r.top * 10) / 10,
right: Math.round(r.right * 10) / 10,
bottom: Math.round(r.bottom * 10) / 10,
left: Math.round(r.left * 10) / 10
};
var vis = r.width > 0 && r.height > 0 &&
cs.visibility !== 'hidden' && cs.display !== 'none' &&
parseFloat(cs.opacity) > 0;
var inVp = r.bottom > 0 && r.top < vh && r.right > 0 && r.left < vw;
elements.push({
selector: sel,
index: ei,
tag: el.tagName.toLowerCase(),
rect: rect,
computed: {
position: cs.position,
display: cs.display,
visibility: cs.visibility,
font_size: cs.fontSize,
flex_direction: cs.flexDirection,
flex_wrap: cs.flexWrap,
grid_template_columns: cs.gridTemplateColumns
},
visible: vis,
in_viewport: inVp
});
}
}
return '__FF_RDP_JSON__' + JSON.stringify({elements: elements, viewport: {width: vw, height: vh}});
})()";
const GET_VIEWPORT_JS: &str =
"JSON.stringify({innerWidth: window.innerWidth, innerHeight: window.innerHeight})";
const SET_VIEWPORT_CSS_JS: &str = "(function(){
var w = __WIDTH__;
document.documentElement.style.setProperty('width', w + 'px', 'important');
document.documentElement.style.setProperty('max-width', w + 'px', 'important');
document.documentElement.style.setProperty('overflow-x', 'hidden', 'important');
document.body.style.setProperty('max-width', w + 'px', 'important');
})()";
const WAIT_LAYOUT_STABLE_JS: &str = "new Promise(function(resolve) {
requestAnimationFrame(function() { setTimeout(resolve, 0); });
})";
const RESTORE_VIEWPORT_CSS_JS: &str = "(function(){
document.documentElement.style.removeProperty('width');
document.documentElement.style.removeProperty('max-width');
document.documentElement.style.removeProperty('overflow-x');
document.body.style.removeProperty('max-width');
})()";
pub(crate) fn build_geometry_js(selectors: &[String]) -> String {
let selectors_json = serde_json::to_string(selectors).unwrap_or_else(|e| {
unreachable!("serde_json::to_string is infallible for Vec<String>: {e}")
});
RESPONSIVE_JS_TEMPLATE.replace("__SELECTORS__", &selectors_json)
}
fn build_set_viewport_js(width: u32) -> String {
SET_VIEWPORT_CSS_JS.replace("__WIDTH__", &width.to_string())
}
fn validate_widths(widths: &[u32]) -> Result<(), AppError> {
if widths.is_empty() {
return Err(AppError::User(
"at least one width must be specified via --widths".to_string(),
));
}
for &w in widths {
if w == 0 {
return Err(AppError::User(
"viewport width must be greater than 0".to_string(),
));
}
}
Ok(())
}
pub fn run(cli: &Cli, selectors: &[String], widths: &[u32]) -> Result<(), AppError> {
validate_widths(widths)?;
let mut ctx = connect_and_get_target(cli)?;
let console_actor = ctx.target.console_actor.clone();
let vp_result =
WebConsoleActor::evaluate_js_async(ctx.transport_mut(), &console_actor, GET_VIEWPORT_JS)
.map_err(AppError::from)?;
if let Some(ref exc) = vp_result.exception {
let msg = exc.message.as_deref().unwrap_or("viewport query failed");
return Err(AppError::User(format!("get viewport: {msg}")));
}
let vp_json_str = match &vp_result.result {
ff_rdp_core::Grip::Value(Value::String(s)) => s.clone(),
other => {
return Err(AppError::User(format!(
"unexpected viewport result: {}",
other.to_json()
)));
}
};
let original_viewport: Value = serde_json::from_str(&vp_json_str)
.map_err(|e| AppError::from(anyhow::anyhow!("failed to parse viewport JSON: {e}")))?;
let geom_js = build_geometry_js(selectors);
let mut breakpoints: Vec<Value> = Vec::with_capacity(widths.len());
let mut loop_error: Option<AppError> = None;
'bp: for &width in widths {
let set_vp_js = build_set_viewport_js(width);
match WebConsoleActor::evaluate_js_async(ctx.transport_mut(), &console_actor, &set_vp_js)
.map_err(AppError::from)
{
Err(e) => {
loop_error = Some(e);
break 'bp;
}
Ok(r) => {
if let Some(ref exc) = r.exception {
let msg = exc.message.as_deref().unwrap_or("set viewport failed");
loop_error = Some(AppError::User(format!("set viewport at {width}: {msg}")));
break 'bp;
}
}
}
match WebConsoleActor::evaluate_js_async(
ctx.transport_mut(),
&console_actor,
WAIT_LAYOUT_STABLE_JS,
)
.map_err(AppError::from)
{
Err(e) => {
loop_error = Some(e);
break 'bp;
}
Ok(r) => {
if let Some(ref exc) = r.exception {
let msg = exc.message.as_deref().unwrap_or("layout wait failed");
loop_error = Some(AppError::User(format!("layout wait at {width}: {msg}")));
break 'bp;
}
}
}
let geo_result =
WebConsoleActor::evaluate_js_async(ctx.transport_mut(), &console_actor, &geom_js)
.map_err(AppError::from);
let geo_result = match geo_result {
Ok(r) => r,
Err(e) => {
loop_error = Some(e);
break 'bp;
}
};
if let Some(ref exc) = geo_result.exception {
let msg = exc
.message
.as_deref()
.unwrap_or("geometry evaluation failed");
loop_error = Some(AppError::User(format!("geometry at {width}: {msg}")));
break 'bp;
}
let geometry = match resolve_result(&mut ctx, &geo_result.result) {
Ok(v) => v,
Err(e) => {
loop_error = Some(e);
break 'bp;
}
};
let elements = geometry["elements"].clone();
let viewport = geometry["viewport"].clone();
breakpoints.push(json!({
"width": width,
"viewport": viewport,
"elements": elements,
}));
}
let _ = WebConsoleActor::evaluate_js_async(
ctx.transport_mut(),
&console_actor,
RESTORE_VIEWPORT_CSS_JS,
);
if let Some(e) = loop_error {
return Err(e);
}
let breakpoint_count = breakpoints.len();
let results = json!({
"breakpoints": breakpoints,
"original_viewport": original_viewport,
});
let mut meta = json!({
"host": cli.host,
"port": cli.port,
"selectors": selectors,
"widths": widths,
});
crate::connection_meta::merge_into(&mut meta, &cli.host, cli.port, None);
if cli.format == "text" && cli.jq.is_none() {
render_responsive_text(&results);
return Ok(());
}
let envelope = output::envelope(&results, breakpoint_count, &meta);
let hint_ctx = HintContext::new(HintSource::Responsive);
OutputPipeline::from_cli(cli)?
.finalize_with_hints(&envelope, Some(&hint_ctx))
.map_err(AppError::from)
}
fn render_responsive_text(results: &Value) {
let Some(breakpoints) = results.get("breakpoints").and_then(Value::as_array) else {
return;
};
for bp in breakpoints {
let width = bp.get("width").and_then(Value::as_u64).unwrap_or(0);
let vp_w = bp
.get("viewport")
.and_then(|v| v.get("width"))
.and_then(Value::as_u64)
.unwrap_or(width);
let vp_h = bp
.get("viewport")
.and_then(|v| v.get("height"))
.and_then(Value::as_u64)
.unwrap_or(0);
println!("=== Breakpoint {width}px (viewport {vp_w}x{vp_h}) ===");
let elements = match bp.get("elements").and_then(Value::as_array) {
Some(e) if !e.is_empty() => e,
_ => {
println!(" (no elements)");
println!();
continue;
}
};
let sel_width = elements
.iter()
.filter_map(|e| e.get("selector").and_then(Value::as_str))
.map(str::len)
.max()
.unwrap_or(8)
.max(8);
println!(
" {:<sel_width$} {:>5} {:>8} {:>8} {:>7} {:>10}",
"selector", "tag", "width", "height", "visible", "in_viewport"
);
println!(
" {} {} {} {} {} {}",
"-".repeat(sel_width),
"-".repeat(5),
"-".repeat(8),
"-".repeat(8),
"-".repeat(7),
"-".repeat(10)
);
for el in elements {
let selector = el.get("selector").and_then(Value::as_str).unwrap_or("?");
let tag = el.get("tag").and_then(Value::as_str).unwrap_or("?");
let el_w = el
.get("rect")
.and_then(|r| r.get("width"))
.and_then(Value::as_f64)
.unwrap_or(0.0);
let el_h = el
.get("rect")
.and_then(|r| r.get("height"))
.and_then(Value::as_f64)
.unwrap_or(0.0);
let visible = el
.get("visible")
.and_then(Value::as_bool)
.map_or("?", |b| if b { "yes" } else { "no" });
let in_vp = el
.get("in_viewport")
.and_then(Value::as_bool)
.map_or("?", |b| if b { "yes" } else { "no" });
println!(
" {selector:<sel_width$} {tag:>5} {el_w:>8.1} {el_h:>8.1} {visible:>7} {in_vp:>10}"
);
}
println!();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_geometry_js_inserts_selectors() {
let selectors = vec!["h1".to_owned(), "p".to_owned()];
let js = build_geometry_js(&selectors);
assert!(js.contains(r#"["h1","p"]"#));
assert!(!js.contains("__SELECTORS__"));
}
#[test]
fn build_geometry_js_contains_sentinel() {
let js = build_geometry_js(&["div".to_owned()]);
assert!(js.contains(super::super::js_helpers::JSON_SENTINEL));
}
#[test]
fn build_geometry_js_escapes_special_chars() {
let selectors = vec!["[data-id=\"foo\"]".to_owned()];
let js = build_geometry_js(&selectors);
assert!(!js.contains("__SELECTORS__"));
let start = js.find("var selectors = ").expect("placeholder replaced") + 16;
let end = js[start..].find(';').expect("semicolon") + start;
let arr: serde_json::Value =
serde_json::from_str(&js[start..end]).expect("selectors must be valid JSON");
assert_eq!(arr[0], "[data-id=\"foo\"]");
}
#[test]
fn validate_widths_rejects_zero() {
let err = validate_widths(&[320, 0, 1024]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("greater than 0"), "unexpected message: {msg}");
}
#[test]
fn validate_widths_rejects_empty() {
let err = validate_widths(&[]).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("at least one width"),
"unexpected message: {msg}"
);
}
#[test]
fn validate_widths_accepts_valid() {
assert!(validate_widths(&[320, 768, 1024, 1440]).is_ok());
}
#[test]
fn build_geometry_js_contains_responsive_properties() {
let js = build_geometry_js(&["div".to_owned()]);
assert!(js.contains("getBoundingClientRect"));
assert!(js.contains("getComputedStyle"));
assert!(js.contains("flexDirection"));
assert!(js.contains("gridTemplateColumns"));
assert!(js.contains("fontSize"));
}
#[test]
fn build_set_viewport_js_substitutes_width() {
let js = build_set_viewport_js(320);
assert!(js.contains("var w = 320;"), "expected width substitution");
assert!(!js.contains("__WIDTH__"), "placeholder should be replaced");
}
#[test]
fn build_set_viewport_js_uses_important() {
let js = build_set_viewport_js(768);
assert!(js.contains("'important'"), "must use !important");
}
#[test]
fn restore_viewport_css_js_removes_all_properties() {
assert!(RESTORE_VIEWPORT_CSS_JS.contains("removeProperty('width')"));
assert!(RESTORE_VIEWPORT_CSS_JS.contains("removeProperty('max-width')"));
assert!(RESTORE_VIEWPORT_CSS_JS.contains("removeProperty('overflow-x')"));
}
#[test]
fn wait_layout_stable_js_returns_promise() {
assert!(WAIT_LAYOUT_STABLE_JS.contains("Promise"));
assert!(WAIT_LAYOUT_STABLE_JS.contains("requestAnimationFrame"));
}
#[test]
fn geometry_js_uses_offset_width_for_vw() {
assert!(RESPONSIVE_JS_TEMPLATE.contains("documentElement.offsetWidth"));
assert!(!RESPONSIVE_JS_TEMPLATE.starts_with("window.innerWidth"));
}
#[test]
fn render_responsive_text_does_not_panic_with_no_breakpoints() {
render_responsive_text(&serde_json::json!({"breakpoints": []}));
}
#[test]
fn render_responsive_text_does_not_panic_with_full_data() {
let data = serde_json::json!({
"breakpoints": [
{
"width": 320,
"viewport": {"width": 320, "height": 768},
"elements": [
{
"selector": "h1",
"tag": "h1",
"rect": {"width": 300.0, "height": 40.0},
"visible": true,
"in_viewport": true,
},
],
},
{
"width": 1024,
"viewport": {"width": 1024, "height": 768},
"elements": [],
},
],
});
render_responsive_text(&data);
}
#[test]
fn render_responsive_text_does_not_panic_with_missing_breakpoints_key() {
render_responsive_text(&serde_json::json!({}));
}
}