use std::time::{Duration, Instant};
use anyhow::Context;
use ff_rdp_core::{ActorId, Grip, LongStringActor, WebConsoleActor, WindowGlobalTarget};
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::{ConnectedTab, connect_and_get_target};
use super::perf::{
compute_cls, compute_fcp, compute_lcp, compute_tbt, compute_ttfb, is_lcp_approximate, round2,
};
use super::url_validation::validate_url;
const POLL_INTERVAL_MS: u64 = 100;
pub(crate) fn validate_labels(urls: &[String], labels: Option<&[String]>) -> Result<(), AppError> {
if let Some(lbls) = labels
&& lbls.len() != urls.len()
{
return Err(AppError::User(format!(
"--label count ({}) must match URL count ({})",
lbls.len(),
urls.len()
)));
}
Ok(())
}
fn label_for(urls: &[String], labels: Option<&[String]>, i: usize) -> String {
labels
.and_then(|lbls| lbls.get(i))
.cloned()
.unwrap_or_else(|| urls[i].clone())
}
fn navigate_and_wait(
ctx: &mut ConnectedTab,
target_actor: &ActorId,
url: &str,
timeout_ms: u64,
) -> Result<(), AppError> {
WindowGlobalTarget::navigate_to(ctx.transport_mut(), target_actor, url)
.map_err(AppError::from)?;
let console_actor = ctx.target.console_actor.clone();
let timeout = Duration::from_millis(timeout_ms);
let poll = Duration::from_millis(POLL_INTERVAL_MS);
let started = Instant::now();
loop {
let eval_result = WebConsoleActor::evaluate_js_async(
ctx.transport_mut(),
&console_actor,
"document.readyState",
)
.map_err(AppError::from)?;
if let Some(ref exc) = eval_result.exception {
let msg = exc.message.as_deref().unwrap_or("evaluation error");
return Err(AppError::User(format!(
"perf compare: readyState check failed for {url}: {msg}"
)));
}
let ready = matches!(
&eval_result.result,
Grip::Value(Value::String(s)) if s == "complete"
);
if ready {
break;
}
if started.elapsed() >= timeout {
return Err(AppError::User(format!(
"perf compare: page did not reach readyState=complete within {timeout_ms}ms for {url}"
)));
}
std::thread::sleep(poll);
}
std::thread::sleep(Duration::from_millis(200));
Ok(())
}
const COLLECT_SCRIPT: &str = r"(function() {
var result = {};
var cwvTypes = ['largest-contentful-paint', 'layout-shift', 'longtask', 'paint'];
cwvTypes.forEach(function(type) {
try {
result[type] = [];
var obs = new PerformanceObserver(function(list) {
result[type] = result[type].concat(list.getEntries().map(function(e) { return e.toJSON(); }));
});
obs.observe({ type: type, buffered: true });
obs.disconnect();
} catch(e) {}
});
if (!result.paint || result.paint.length === 0) {
result.paint = performance.getEntriesByType('paint').map(function(e) { return e.toJSON(); });
}
// LCP layer 2: direct getEntriesByType query if observer returned nothing
if (!result['largest-contentful-paint'] || result['largest-contentful-paint'].length === 0) {
try {
var direct = performance.getEntriesByType('largest-contentful-paint');
if (direct && direct.length > 0) {
result['largest-contentful-paint'] = direct.map(function(e) { return e.toJSON(); });
}
} catch(e) {}
}
// LCP layer 3: DOM-based approximation if still empty
if (!result['largest-contentful-paint'] || result['largest-contentful-paint'].length === 0) {
try {
var best = null;
var bestArea = 0;
var candidates = Array.prototype.slice.call(
document.querySelectorAll('img, video, svg, canvas, [style*=background-image]')
);
candidates.forEach(function(el) {
var rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) { return; }
var area = rect.width * rect.height;
if (area > bestArea) { bestArea = area; best = el; }
});
if (best) {
var src = best.src || best.currentSrc || best.getAttribute('src') || '';
var loadTime = 0;
if (src) {
var res = performance.getEntriesByName(src);
if (res && res.length > 0) { loadTime = res[0].responseEnd || 0; }
}
result['largest-contentful-paint'] = [{
entryType: 'largest-contentful-paint',
startTime: loadTime,
renderTime: loadTime,
loadTime: loadTime,
size: bestArea,
url: src,
element: null,
approximate: true
}];
}
} catch(e) {}
}
result.navigation = performance.getEntriesByType('navigation').map(function(e) { return e.toJSON(); });
result.resource = performance.getEntriesByType('resource').map(function(e) { return e.toJSON(); });
return JSON.stringify(result);
})()";
fn eval_to_json_string(
ctx: &mut ConnectedTab,
script: &str,
label: &str,
) -> Result<String, AppError> {
let console_actor = ctx.target.console_actor.clone();
let eval_result =
WebConsoleActor::evaluate_js_async(ctx.transport_mut(), &console_actor, script)
.map_err(AppError::from)?;
if let Some(ref exc) = eval_result.exception {
let msg = exc
.message
.as_deref()
.unwrap_or("evaluation threw an exception");
return Err(AppError::User(format!("{label}: {msg}")));
}
match &eval_result.result {
Grip::Value(Value::String(s)) => Ok(s.clone()),
Grip::LongString {
actor,
length,
initial: _,
} => LongStringActor::full_string(ctx.transport_mut(), actor.as_ref(), *length)
.map_err(AppError::from),
other => Err(AppError::User(format!(
"{label}: expected string result, got: {}",
other.to_json()
))),
}
}
fn collect_page_perf(ctx: &mut ConnectedTab, label: &str) -> Result<Value, AppError> {
let json_str = eval_to_json_string(ctx, COLLECT_SCRIPT, label)?;
let all: Value = serde_json::from_str(&json_str)
.context("perf compare: failed to parse collection JSON")
.map_err(AppError::from)?;
let nav_entries = all.get("navigation").and_then(Value::as_array);
let nav = nav_entries.and_then(|a| a.first());
let paint_entries: &[Value] = all
.get("paint")
.and_then(Value::as_array)
.map_or(&[], Vec::as_slice);
let lcp_entries: &[Value] = all
.get("largest-contentful-paint")
.and_then(Value::as_array)
.map_or(&[], Vec::as_slice);
let cls_entries: &[Value] = all
.get("layout-shift")
.and_then(Value::as_array)
.map_or(&[], Vec::as_slice);
let longtask_entries: &[Value] = all
.get("longtask")
.and_then(Value::as_array)
.map_or(&[], Vec::as_slice);
let ttfb = nav.and_then(compute_ttfb);
let fcp = compute_fcp(paint_entries);
let lcp = compute_lcp(lcp_entries);
let cls = compute_cls(cls_entries);
let tbt = compute_tbt(longtask_entries, fcp);
let lcp_approximate = is_lcp_approximate(lcp_entries);
let mut vitals = json!({
"ttfb_ms": ttfb,
"fcp_ms": fcp,
"lcp_ms": lcp,
"cls": cls,
"tbt_ms": tbt,
});
if lcp_approximate {
vitals["lcp_approximate"] = json!(true);
vitals["lcp_note"] = json!(
"LCP estimated via DOM approximation; not available from PerformanceObserver in headless Firefox"
);
} else if lcp.is_none() {
vitals["lcp_note"] = json!("LCP not available in headless Firefox");
}
let navigation = if let Some(nav_entry) = nav {
let duration_ms = nav_entry
.get("duration")
.and_then(Value::as_f64)
.map(round2);
let transfer_size = nav_entry
.get("transferSize")
.and_then(Value::as_f64)
.map(round2);
let start_time = nav_entry
.get("startTime")
.and_then(Value::as_f64)
.unwrap_or(0.0);
let dom_interactive_ms = nav_entry
.get("domInteractive")
.and_then(Value::as_f64)
.map(|v| round2(v - start_time));
let dom_complete_ms = nav_entry
.get("domComplete")
.and_then(Value::as_f64)
.map(|v| round2(v - start_time));
json!({
"duration_ms": duration_ms,
"transfer_size": transfer_size,
"dom_interactive_ms": dom_interactive_ms,
"dom_complete_ms": dom_complete_ms,
})
} else {
json!({
"duration_ms": null,
"transfer_size": null,
"dom_interactive_ms": null,
"dom_complete_ms": null,
})
};
let raw_resources: &[Value] = all
.get("resource")
.and_then(Value::as_array)
.map_or(&[], Vec::as_slice);
let resource_count = raw_resources.len();
let total_transfer_size: f64 = raw_resources
.iter()
.filter_map(|e| e.get("transferSize").and_then(Value::as_f64))
.sum();
let resources = json!({
"count": resource_count,
"total_transfer_size": round2(total_transfer_size),
});
Ok(json!({
"vitals": vitals,
"navigation": navigation,
"resources": resources,
}))
}
pub fn run(cli: &Cli, urls: &[String], labels: Option<&[String]>) -> Result<(), AppError> {
validate_labels(urls, labels)?;
if !cli.allow_unsafe_urls {
for url in urls {
validate_url(url)?;
}
}
let mut ctx = connect_and_get_target(cli)?;
let target_actor = ctx.target.actor.clone();
let mut results: Vec<Value> = Vec::with_capacity(urls.len());
for (i, url) in urls.iter().enumerate() {
let lbl = label_for(urls, labels, i);
navigate_and_wait(&mut ctx, &target_actor, url, cli.timeout)?;
let perf_data = collect_page_perf(&mut ctx, &lbl)?;
results.push(json!({
"label": lbl,
"url": url,
"vitals": perf_data["vitals"],
"navigation": perf_data["navigation"],
"resources": perf_data["resources"],
}));
}
let total = results.len();
let mut meta = json!({"host": cli.host, "port": cli.port});
crate::connection_meta::merge_into(&mut meta, &cli.host, cli.port, None);
let envelope = output::envelope(&Value::Array(results), total, &meta);
let hint_ctx = HintContext::new(HintSource::Perf);
OutputPipeline::from_cli(cli)?
.finalize_with_hints(&envelope, Some(&hint_ctx))
.map_err(AppError::from)
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &str) -> String {
v.to_string()
}
#[test]
fn validate_labels_no_labels_is_ok() {
let urls = vec![s("https://a.example"), s("https://b.example")];
assert!(validate_labels(&urls, None).is_ok());
}
#[test]
fn validate_labels_matching_count_is_ok() {
let urls = vec![s("https://a.example"), s("https://b.example")];
let labels = vec![s("A"), s("B")];
assert!(validate_labels(&urls, Some(&labels)).is_ok());
}
#[test]
fn validate_labels_too_few_labels_errors() {
let urls = vec![s("https://a.example"), s("https://b.example")];
let labels = vec![s("Only One")];
let err = validate_labels(&urls, Some(&labels)).unwrap_err();
assert!(matches!(err, AppError::User(_)));
let msg = err.to_string();
assert!(msg.contains('1'), "expected label count in error: {msg}");
assert!(msg.contains('2'), "expected url count in error: {msg}");
}
#[test]
fn validate_labels_too_many_labels_errors() {
let urls = vec![s("https://a.example")];
let labels = vec![s("A"), s("B"), s("C")];
let err = validate_labels(&urls, Some(&labels)).unwrap_err();
assert!(matches!(err, AppError::User(_)));
let msg = err.to_string();
assert!(msg.contains('3'), "expected label count in error: {msg}");
assert!(msg.contains('1'), "expected url count in error: {msg}");
}
#[test]
fn label_for_uses_url_when_no_labels() {
let urls = vec![s("https://example.com"), s("https://other.com")];
assert_eq!(label_for(&urls, None, 0), "https://example.com");
assert_eq!(label_for(&urls, None, 1), "https://other.com");
}
#[test]
fn label_for_uses_provided_label() {
let urls = vec![s("https://example.com"), s("https://other.com")];
let labels = vec![s("Home"), s("About")];
assert_eq!(label_for(&urls, Some(&labels), 0), "Home");
assert_eq!(label_for(&urls, Some(&labels), 1), "About");
}
#[test]
fn label_for_falls_back_to_url_when_label_out_of_range() {
let urls = vec![s("https://example.com")];
let labels: Vec<String> = vec![];
assert_eq!(label_for(&urls, Some(&labels), 0), "https://example.com");
}
}