Skip to main content

browser_commander/browser/parity/
comparison.rs

1//! Shared command-line comparison and probe difference rules.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::fingerprint::find_fingerprint_limitation;
9
10/// One unequal probe value. Missing properties are represented as JSON null.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ProbeDifference {
13    /// Dot-separated probe path or command-line switch path.
14    pub path: String,
15    /// Value from the hand-started browser.
16    pub reference: Value,
17    /// Value from the driven browser.
18    pub candidate: Value,
19}
20
21/// An explained difference in the portable parity report.
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct ParityDifference {
24    /// Dot-separated probe or command-line path.
25    pub path: String,
26    /// Reference value.
27    pub expected: Value,
28    /// Driven value.
29    pub actual: Value,
30    /// Identifier in the shared fingerprint limitations catalogue, if explained.
31    pub limitation: Option<String>,
32    /// Whether the caller explicitly requested this difference.
33    pub requested: bool,
34}
35
36/// Values of a comma-separated feature switch, sorted for comparison.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct FeatureComparison {
39    /// Reference browser's features.
40    pub reference: Vec<String>,
41    /// Driven browser's features.
42    pub candidate: Vec<String>,
43}
44
45/// A switch whose value changed.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ChangedSwitch {
48    /// Switch name, including its leading `--`.
49    pub name: String,
50    /// Reference value; `None` denotes a flag without a value.
51    pub reference: Option<String>,
52    /// Driven value.
53    pub candidate: Option<String>,
54}
55
56/// Browser-reported command-line comparison, compatible with JS and Python.
57#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct CommandLineComparison {
59    /// Switches present only in the driven command line.
60    pub extra: Vec<String>,
61    /// Switches missing from the driven command line.
62    pub missing: Vec<String>,
63    /// Unequal values, excluding volatile profile paths.
64    pub changed: Vec<ChangedSwitch>,
65    /// Fixed debugging-port switches used to attach the engine.
66    pub attachment: Vec<String>,
67    /// Feature lists from both command lines.
68    pub features: BTreeMap<String, FeatureComparison>,
69}
70
71/// Context used to explain probe differences without hiding unknown changes.
72#[derive(Debug, Clone)]
73pub struct ParityContext {
74    /// `real` or `engine` launch.
75    pub launch: String,
76    /// Whether the measured browser was started by somebody else.
77    pub attached: bool,
78    /// Extra switches reported by the browser.
79    pub extra_switches: Vec<String>,
80    /// Arguments explicitly requested by the caller.
81    pub requested_args: Vec<String>,
82}
83
84impl Default for ParityContext {
85    fn default() -> Self {
86        Self {
87            launch: "real".into(),
88            attached: false,
89            extra_switches: Vec::new(),
90            requested_args: Vec::new(),
91        }
92    }
93}
94
95/// Parse exact argv entries into switch names and optional values.
96pub fn parse_switch_args(args: &[String]) -> BTreeMap<String, Option<String>> {
97    args.iter()
98        .filter(|arg| arg.starts_with("--"))
99        .map(|arg| match arg.split_once('=') {
100            Some((name, value)) => (name.into(), Some(value.into())),
101            None => (arg.clone(), None),
102        })
103        .collect()
104}
105
106/// Parse the space-joined command line shown by `chrome://version`.
107/// Profile paths containing spaces are regrouped until the next switch.
108pub fn parse_switches(command_line: &str) -> BTreeMap<String, Option<String>> {
109    let mut words: Vec<&str> = command_line.split_whitespace().collect();
110    if words.len() > 1
111        && words
112            .last()
113            .is_some_and(|word| !word.starts_with("--") && url::Url::parse(word).is_ok())
114    {
115        words.pop();
116    }
117    let mut args: Vec<String> = Vec::new();
118    for word in words {
119        if word.starts_with("--") || args.is_empty() {
120            args.push(word.into());
121        } else if let Some(last) = args.last_mut() {
122            last.push(' ');
123            last.push_str(word);
124        }
125    }
126    parse_switch_args(&args)
127}
128
129fn feature_list(value: Option<&Option<String>>) -> Vec<String> {
130    let mut features: Vec<String> = value
131        .and_then(Option::as_deref)
132        .unwrap_or("")
133        .split(',')
134        .map(str::trim)
135        .filter(|entry| !entry.is_empty())
136        .map(str::to_owned)
137        .collect();
138    features.sort();
139    features
140}
141
142fn formatted(name: &str, value: &Option<String>) -> String {
143    match value {
144        Some(value) => format!("{name}={value}"),
145        None => name.into(),
146    }
147}
148
149/// Compare reported switch presence and values, including engine feature lists.
150pub fn compare_command_lines(reference: &str, candidate: &str) -> CommandLineComparison {
151    let left = parse_switches(reference);
152    let right = parse_switches(candidate);
153    let mut comparison = CommandLineComparison::default();
154    for name in left.keys().chain(right.keys()).collect::<BTreeSet<_>>() {
155        if matches!(
156            name.as_str(),
157            "--flag-switches-begin" | "--flag-switches-end"
158        ) {
159            continue;
160        }
161        if name == "--remote-debugging-port" {
162            if let Some(value) = right.get(name) {
163                comparison
164                    .attachment
165                    .push(format!("{name}={}", value.as_deref().unwrap_or("null")));
166            }
167            continue;
168        }
169        match (left.get(name), right.get(name)) {
170            (None, Some(value)) => comparison.extra.push(formatted(name, value)),
171            (Some(value), None) => comparison.missing.push(formatted(name, value)),
172            (Some(a), Some(b)) if name != "--user-data-dir" && a != b => {
173                comparison.changed.push(ChangedSwitch {
174                    name: name.clone(),
175                    reference: a.clone(),
176                    candidate: b.clone(),
177                });
178            }
179            _ => {}
180        }
181        if matches!(
182            name.as_str(),
183            "--enable-features"
184                | "--disable-features"
185                | "--enable-blink-features"
186                | "--disable-blink-features"
187        ) {
188            comparison.features.insert(
189                name.clone(),
190                FeatureComparison {
191                    reference: feature_list(left.get(name)),
192                    candidate: feature_list(right.get(name)),
193                },
194            );
195        }
196    }
197    comparison
198}
199
200pub(super) fn command_line_differences(comparison: &CommandLineComparison) -> Vec<ProbeDifference> {
201    let mut differences = Vec::new();
202    for (kind, entries) in [
203        ("extra", &comparison.extra),
204        ("missing", &comparison.missing),
205    ] {
206        for entry in entries {
207            let value = Value::String(entry.clone());
208            differences.push(ProbeDifference {
209                path: format!(
210                    "commandLine.{kind}.{}",
211                    entry.split('=').next().unwrap_or(entry)
212                ),
213                reference: if kind == "missing" {
214                    value.clone()
215                } else {
216                    Value::Null
217                },
218                candidate: if kind == "extra" { value } else { Value::Null },
219            });
220        }
221    }
222    differences.extend(comparison.changed.iter().map(|switch| ProbeDifference {
223        path: format!("commandLine.changed.{}", switch.name),
224        reference: switch.reference.clone().map_or(Value::Null, Value::String),
225        candidate: switch.candidate.clone().map_or(Value::Null, Value::String),
226    }));
227    differences
228}
229
230fn ignored(path: &str) -> bool {
231    matches!(
232        path,
233        "window.innerWidth"
234            | "window.innerHeight"
235            | "window.outerWidth"
236            | "window.outerHeight"
237            | "window.screenX"
238            | "window.screenY"
239            | "window.screenLeft"
240            | "window.screenTop"
241            | "document.referrer"
242            | "document.hasFocus"
243            | "document.bodyClientHeightIsPositive"
244            | "connection.downlink"
245            | "connection.rtt"
246    ) || path.starts_with("viewportRelation.")
247        || path.starts_with("probeErrors.")
248}
249
250/// Deep-diff objects in sorted key order. Arrays and scalars compare whole.
251/// Missing values remain distinct from present JSON null while comparing.
252pub fn diff_reports(reference: &Value, candidate: &Value) -> Vec<ProbeDifference> {
253    fn walk(
254        left: Option<&Value>,
255        right: Option<&Value>,
256        path: &str,
257        output: &mut Vec<ProbeDifference>,
258    ) {
259        if ignored(path) {
260            return;
261        }
262        if let (Some(Value::Object(a)), Some(Value::Object(b))) = (left, right) {
263            for key in a.keys().chain(b.keys()).collect::<BTreeSet<_>>() {
264                let trail = if path.is_empty() {
265                    key.clone()
266                } else {
267                    format!("{path}.{key}")
268                };
269                walk(a.get(key), b.get(key), &trail, output);
270            }
271        } else if left != right {
272            output.push(ProbeDifference {
273                path: path.into(),
274                reference: left.cloned().unwrap_or(Value::Null),
275                candidate: right.cloned().unwrap_or(Value::Null),
276            });
277        }
278    }
279    let mut output = Vec::new();
280    walk(Some(reference), Some(candidate), "", &mut output);
281    output
282}
283
284/// Tag differences using the shared catalogue and return the unexplained subset.
285pub fn classify_differences(
286    differences: &[ProbeDifference],
287    context: &ParityContext,
288) -> (Vec<ParityDifference>, Vec<ParityDifference>) {
289    let tagged: Vec<ParityDifference> = differences
290        .iter()
291        .map(|difference| {
292            let command_line = difference.path.starts_with("commandLine.");
293            let requested = command_line
294                && difference
295                    .candidate
296                    .as_str()
297                    .or(difference.reference.as_str())
298                    .is_some_and(|value| context.requested_args.iter().any(|arg| arg == value));
299            let feature_config = context.extra_switches.iter().any(|arg| {
300                matches!(
301                    arg.split('=').next(),
302                    Some(
303                        "--disable-field-trial-config" | "--disable-features" | "--enable-features"
304                    )
305                )
306            });
307            let limitation = if requested {
308                None
309            } else if context.launch == "engine"
310                && (command_line
311                    || (feature_config
312                        && (difference.path.ends_with(".keys")
313                            || difference.path.ends_with(".languages"))))
314            {
315                Some("engine-launch-switches")
316            } else if difference
317                .path
318                .starts_with("navigator.userAgentData.brands")
319            {
320                Some("grease-brand-not-reproduced")
321            } else if difference.path == "navigator.webdriver" && context.attached {
322                Some("automation-controlled-is-launch-only")
323            } else {
324                None
325            };
326            ParityDifference {
327                path: difference.path.clone(),
328                expected: difference.reference.clone(),
329                actual: difference.candidate.clone(),
330                limitation: limitation
331                    .filter(|id| find_fingerprint_limitation(id).is_some())
332                    .map(str::to_owned),
333                requested,
334            }
335        })
336        .collect();
337    let unlisted = tagged
338        .iter()
339        .filter(|entry| entry.limitation.is_none() && !entry.requested)
340        .cloned()
341        .collect();
342    (tagged, unlisted)
343}