Skip to main content

browser_commander/browser/parity/
mod.rs

1//! Measure driven browsers against the same binary started by hand.
2//!
3//! The environment reference is a plain command-stream child with no CDP
4//! endpoint. Both reference and candidate load the same loopback probe page
5//! and POST their reports. A separate reference launch reads `chrome://version`
6//! with a fixed debugging port to capture switches Chrome appends itself.
7
8mod comparison;
9mod reference;
10mod server;
11
12use std::path::Path;
13use std::time::Duration;
14
15use anyhow::{Context, Result};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19use super::launch_executable::DefaultLaunchHooks;
20use super::real_browser::LaunchHooks;
21use super::{launch_browser, LaunchOptions, LaunchResult};
22use crate::core::{EngineAdapter, EngineType};
23
24use comparison::command_line_differences;
25pub use comparison::{
26    classify_differences, compare_command_lines, diff_reports, parse_switch_args, parse_switches,
27    ChangedSwitch, CommandLineComparison, FeatureComparison, ParityContext, ParityDifference,
28    ProbeDifference,
29};
30pub use reference::build_reference_args;
31use server::ProbeServer;
32
33/// Canonical environment probe, embedded so crates.io installs need no npm package.
34pub const PROBE_SOURCE: &str = include_str!("probe.js");
35
36pub(crate) const VERSION_EXPRESSION: &str = r#"(() => {
37  const text = id => (document.getElementById(id)?.textContent ?? '').trim();
38  if (!text('command_line')) return null;
39  return { commandLine:text('command_line'), version:text('version'), executablePath:text('executable_path') };
40})()"#;
41
42/// Options for native browser parity measurement.
43#[derive(Debug, Clone)]
44pub struct MeasureParityOptions {
45    /// Candidate launch options, including engine, launch mode and requested restrictions.
46    pub launch: LaunchOptions,
47    /// Whether a borrowed session was started by somebody else.
48    pub attached: bool,
49    /// Maximum duration of each capture; defaults to sixty seconds.
50    pub timeout: Duration,
51    /// Extra arguments for both reference launches. Empty preserves a hand-started baseline.
52    /// Container callers can explicitly supply `--no-sandbox` here.
53    pub reference_args: Vec<String>,
54}
55
56impl Default for MeasureParityOptions {
57    fn default() -> Self {
58        Self {
59            launch: LaunchOptions::default(),
60            attached: false,
61            timeout: Duration::from_secs(60),
62            reference_args: Vec::new(),
63        }
64    }
65}
66
67/// Browser metadata in the portable parity report.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct ParityBrowser {
71    /// Binary used for both captures.
72    pub executable_path: String,
73    /// Version Chrome reports.
74    pub version: String,
75    /// Candidate engine.
76    pub engine: EngineType,
77    /// `real` or `engine` launch.
78    pub launch: String,
79    /// Whether the browser ran headlessly.
80    pub headless: bool,
81}
82
83/// Browser-reported command lines and their comparison.
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ParityCommandLine {
86    /// Candidate's `chrome://version` command line.
87    pub launched: String,
88    /// Reference's `chrome://version` command line.
89    pub reference: String,
90    /// Switch comparison fields, flattened to the shared schema.
91    #[serde(flatten)]
92    pub comparison: CommandLineComparison,
93}
94
95/// Typed report serialized identically to JavaScript/Python `measureParity`.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct ParityReport {
99    /// Browser and launch metadata.
100    pub browser: ParityBrowser,
101    /// Chrome's actual command lines, including its own appended switches.
102    pub command_line: ParityCommandLine,
103    /// Every measured difference, with explanations where available.
104    pub differences: Vec<ParityDifference>,
105    /// Differences not explained by a requested option or shared limitation.
106    pub unlisted: Vec<ParityDifference>,
107    /// True only when `unlisted` is empty.
108    pub ok: bool,
109}
110
111async fn capture_candidate(
112    session: &LaunchResult,
113    server: &ProbeServer,
114    timeout: Duration,
115) -> Result<(Value, Value)> {
116    tokio::time::timeout(timeout, session.page.goto(&server.candidate_url)).await??;
117    let report = server.report(false, timeout).await?;
118    let version = tokio::time::timeout(timeout, session.page.read_browser_version_page()).await??;
119    Ok((report, version))
120}
121
122fn assemble_report(
123    session: &LaunchResult,
124    options: &MeasureParityOptions,
125    executable: &Path,
126    reference: Value,
127    reference_version: Value,
128    candidate: Value,
129    candidate_version: Value,
130) -> Result<ParityReport> {
131    let command = |value: &Value| {
132        value
133            .get("commandLine")
134            .and_then(Value::as_str)
135            .map(str::to_owned)
136            .context("chrome://version did not report its command line")
137    };
138    let launched = command(&candidate_version)?;
139    let reference_command = command(&reference_version)?;
140    let comparison = compare_command_lines(&reference_command, &launched);
141    let mut raw_differences = command_line_differences(&comparison);
142    raw_differences.extend(diff_reports(&reference, &candidate));
143    let mut requested_args = options.launch.args.clone();
144    requested_args.extend(options.launch.extra_args.clone());
145    if !options.launch.sandbox {
146        requested_args.push("--no-sandbox".into());
147    }
148    if !options.launch.restrictions.is_empty() {
149        requested_args.extend(
150            session
151                .args
152                .iter()
153                .filter(|arg| {
154                    !arg.starts_with("--user-data-dir")
155                        && !arg.starts_with("--remote-debugging-port")
156                })
157                .cloned(),
158        );
159    }
160    let launch = session
161        .launch
162        .unwrap_or(options.launch.launch)
163        .as_str()
164        .to_owned();
165    let (differences, unlisted) = classify_differences(
166        &raw_differences,
167        &ParityContext {
168            launch: launch.clone(),
169            attached: options.attached,
170            extra_switches: comparison.extra.clone(),
171            requested_args,
172        },
173    );
174    Ok(ParityReport {
175        browser: ParityBrowser {
176            executable_path: executable.to_string_lossy().into_owned(),
177            version: candidate_version
178                .get("version")
179                .and_then(Value::as_str)
180                .unwrap_or("")
181                .split_whitespace()
182                .collect::<Vec<_>>()
183                .join(" "),
184            engine: session.browser.engine,
185            launch,
186            headless: session.browser.headless,
187        },
188        command_line: ParityCommandLine {
189            launched,
190            reference: reference_command,
191            comparison,
192        },
193        ok: unlisted.is_empty(),
194        differences,
195        unlisted,
196    })
197}
198
199async fn measure(
200    options: MeasureParityOptions,
201    supplied: Option<&LaunchResult>,
202) -> Result<ParityReport> {
203    let real = options.launch.real_browser_options();
204    let executable =
205        if let Some(path) = supplied.and_then(|session| session.executable_path.clone()) {
206            path
207        } else {
208            DefaultLaunchHooks {
209                explicit_selection: options.launch.executable_path.is_some()
210                    || options.launch.channel.is_some(),
211            }
212            .resolve_executable(&real)?
213        };
214    let server = ProbeServer::start().await?;
215    let reference = reference::capture(&server, &executable, &options).await?;
216    // A separate clean CDP launch reads version metadata without attaching to
217    // the environment reference, which has already exited at this point.
218    let version_options = LaunchOptions::chromiumoxide()
219        .executable_path(&executable)
220        .headless(options.launch.headless)
221        .with_extra_args(options.reference_args.clone());
222    let reference_session = launch_browser(version_options).await?;
223    let reference_version = tokio::time::timeout(
224        options.timeout,
225        reference_session.page.read_browser_version_page(),
226    )
227    .await;
228    let reference_closed = reference_session.close().await;
229    let reference_version = reference_version??;
230    reference_closed?;
231    if let Some(session) = supplied {
232        let (candidate, candidate_version) =
233            capture_candidate(session, &server, options.timeout).await?;
234        assemble_report(
235            session,
236            &options,
237            &executable,
238            reference,
239            reference_version,
240            candidate,
241            candidate_version,
242        )
243    } else {
244        let mut launch = options.launch.clone();
245        launch.executable_path = Some(executable.clone());
246        let session = launch_browser(launch).await?;
247        let captured = capture_candidate(&session, &server, options.timeout).await;
248        let closed = session.close().await;
249        let (candidate, candidate_version) = captured?;
250        closed?;
251        assemble_report(
252            &session,
253            &options,
254            &executable,
255            reference,
256            reference_version,
257            candidate,
258            candidate_version,
259        )
260    }
261}
262
263/// Launch and measure a browser, then close it and remove its owned temporary profile.
264pub async fn measure_parity(options: MeasureParityOptions) -> Result<ParityReport> {
265    measure(options, None).await
266}
267
268/// Measure an existing session without closing it. Navigation goes to the probe page.
269/// The caller remains responsible for closing the supplied browser.
270pub async fn measure_session_parity(
271    session: &LaunchResult,
272    mut options: MeasureParityOptions,
273) -> Result<ParityReport> {
274    options.launch.headless = session.browser.headless;
275    options.launch.engine = session.browser.engine;
276    measure(options, Some(session)).await
277}
278
279/// Read version metadata from a fresh tab, leaving the measured page untouched.
280pub async fn read_browser_version_page(page: &dyn EngineAdapter) -> Result<Value> {
281    Ok(page.read_browser_version_page().await?)
282}